Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 미배포 — KAC 주차요금 필드 정합성

- 실제 주차장 이름과 일 최대요금 필드의 우선순위를 고치고, `ParkingFee`에 양 차종의
무료시간·추가 단위요금·휴일 전체 요금을 추가했다. 기존 생성자는 유지한다.
- 누락과 실제 `0`을 구분하며 음수·소수 요금을 정상 값으로 절삭하지 않는다.

## 수집 승인용 주차장 값 검증 추가

- parse_parking_snapshot_values/ParkingSnapshotValues로 원문 오류를 현재시각/0으로 바꾸지 않는다. 기존 API는 유지한다.
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,36 @@ class ParkingFee(KrairportModel):
large_basic_fee: int | None
small_daily_max_fee: int | None
large_daily_max_fee: int | None
airport_name: str | None = None
small_free_minutes: int | None = None
small_unit_minutes: int | None = None
small_unit_fee: int | None = None
large_free_minutes: int | None = None
large_unit_minutes: int | None = None
large_unit_fee: int | None = None
small_holiday_free_minutes: int | None = None
small_holiday_basic_minutes: int | None = None
small_holiday_basic_fee: int | None = None
small_holiday_unit_minutes: int | None = None
small_holiday_unit_fee: int | None = None
small_holiday_daily_max_fee: int | None = None
large_holiday_free_minutes: int | None = None
large_holiday_basic_minutes: int | None = None
large_holiday_basic_fee: int | None = None
large_holiday_unit_minutes: int | None = None
large_holiday_unit_fee: int | None = None
large_holiday_daily_max_fee: int | None = None
raw: dict
```

`parking_fees()`는 KAC의 실제 `parkingParkingName`, `parkingMaxAccount(d)`와
`parkingHoli*`를 정규화합니다. 기존 이름·최대요금 별칭도 유지하며 실제 필드를 먼저
사용합니다. 빈 값과 `-`는 `None`, 명시적인 `0`은 `0`으로 보존합니다. 음수·소수·비수치
요금을 절삭하거나 무료 요금으로 바꾸지 않고 `KrairportParseError`로 거부합니다.
쉼표는 정상적인 세 자리 천단위 묶음만 허용하고, 손상된 구분 표기는 추정하지 않고 `None`으로 남깁니다.
추가 필드는 기본값 `None`이라 기존 생성자 호출을 유지합니다. 이 모델은 공급자 요금표이며
공휴일 판정·주차장 연결·무료 회차 적용·최종 주차요금 계산은 포함하지 않습니다.

### `ArrivalCongestion`

```python
Expand Down
5 changes: 5 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ Optional live test는 `live_kac` 또는 `live_iiac` marker를 사용하고 match

## 흔한 실수

- KAC 요금의 `parkingParkingName`, `parkingMaxAccount(d)`, `parkingHoli*`를
누락하지 않는다. `ParkingFee`에서 양 차종의 평일·휴일 무료/기본/단위/최대요금을
정규화하며, 없는 값은 `None`, 실제 `0`은 그대로 보존한다. 음수·소수 요금을 절삭하지 않는다.
공휴일·무료 회차·24시간 적용 정책을 API 응답만으로 추정하여 모델에 넣지 않는다.

- GW 페이지의 누락 메타데이터를 0으로 바꿔 완전한 빈 결과처럼 반환하지 않는다.
서버 응답 범위 확인과 SDK 품질 정책은 [GW 계약](docs/kac-flight-gateway.md)을 따른다.

Expand Down
6 changes: 5 additions & 1 deletion docs/api-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
| `departures()` | IIAC | `StatusOfPassengerFlightsOdp/getPassengerDeparturesOdp`, `StatusOfPassengerFlightsDeOdp/getPassengerDeparturesDeOdp` |
| `arrivals()` | IIAC | `StatusOfPassengerFlightsOdp/getPassengerArrivalsOdp`, `StatusOfPassengerFlightsDeOdp/getPassengerArrivalsDeOdp` |
| `aircraft_assignments()` | KAC | `FlightStatusAPLList/getFlightStatusAPLList` |
| `parking_fees()` | KAC | `AirportParkingFee/parkingfee` |
| `parking_fees()` | KAC | `AirportParkingFee/parkingfee` (평일·휴일, 소형·대형 전체 요금 필드) |
| `parking_status()` | KAC | `AirportParkingCongestion/airportParkingCongestionRT` |
| `parking_status()` | IIAC | `StatusOfParking/getTrackingParking` |
| `arrival_congestion()` | IIAC | `StatusOfArrivals/getArrivalsCongestion` |
Expand Down Expand Up @@ -129,3 +129,7 @@ client.iiac_raw_items("ShtbusInfo", "getShtbusInfo", {"pageNo": 1})
- IIAC 통계성 API

typed 모델로 승격할 때는 fixture를 먼저 추가하고, 필드별 Python 타입 assertion을 함께 추가합니다.

주차요금 typed 응답은 소형/대형의 평일·휴일 무료시간, 기본시간/요금,
추가 단위시간/요금, 일 최대요금과 실제 주차장 이름을 포함합니다.
제공되지 않은 값은 `None`이며 최종 요금·할인·공휴일 정책을 계산하지 않습니다.
7 changes: 7 additions & 0 deletions docs/repeated-mistakes.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,10 @@
- 가드레일: `src/krairport/providers/kac.py`의 모든 base URL 상수와 `raw_items()`의 f-string은
`http://openapi.airport.co.kr/...`를 사용한다. 새 KAC endpoint를 추가할 때 `https://`로 되돌리지
않는다. IIAC(`apis.data.go.kr`)는 정상적으로 `https://`이므로 이 규칙과 혼동하지 않는다.

## 주차요금 실제 필드 누락

`parkingName`/`parkingDayMaxAccount`만 읽으면 실제 KAC의 `parkingParkingName`/
`parkingMaxAccount`가 typed 응답에서 사라진다. 휴일·추가 단위요금도 같은 모델에
정규화해야 소비자가 provider 원문 파싱을 다시 만들지 않는다. 원문에서 값이 없는 경우와
명시적인 `0`을 구분하고, 소수 요금을 정수로 절삭하지 않는다.
17 changes: 17 additions & 0 deletions krairport-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,23 @@ Coordinate
KrairportModel
```

### 주차요금 필드

`ParkingFee`의 기존 소형/대형 기본시간·기본요금·일 최대요금에 더해
각 차종의 `free_minutes`, `unit_minutes`, `unit_fee`와
`holiday_free_minutes`, `holiday_basic_minutes`, `holiday_basic_fee`,
`holiday_unit_minutes`, `holiday_unit_fee`, `holiday_daily_max_fee`를
`small_`/`large_` 접두어로 제공합니다. 새 필드와 `airport_name`은 모두 기본값 `None`입니다.

KAC `parkingParkingName`을 주차장 이름의 첫 번째 출처로,
`parkingMaxAccount`/`parkingMaxAccountd`를 일 최대요금의 첫 번째 출처로 사용합니다.
이전 `parkingName`/`parkingArea`/`parkingDiv`, `parkingDayMaxAccount(d)`/
`parkingOneDayAccount(d)`는 값이 없을 때만 사용합니다. 휴일은 `parkingHoli*`에서
읽으며 평일 값으로 휴일 필드를 추정하지 않습니다. 정수형 원/분만 허용합니다.
잘못된 천단위 쉼표 표기는 원문을 보존하며 `None`으로 처리합니다. 정상적인 세 자리
천단위 묶음과 쉼표 없는 정수만 정규화합니다.
음수·소수·비수치는 파싱 오류입니다. `raw`와 기존 공항 라우팅은 그대로 유지합니다.

## 5. 타입 변환 규칙

### 5.1 날짜/시간
Expand Down
19 changes: 19 additions & 0 deletions src/krairport/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,25 @@ class ParkingFee(KrairportModel):
large_basic_fee: int | None
small_daily_max_fee: int | None
large_daily_max_fee: int | None
airport_name: str | None = None
small_free_minutes: int | None = None
small_unit_minutes: int | None = None
small_unit_fee: int | None = None
large_free_minutes: int | None = None
large_unit_minutes: int | None = None
large_unit_fee: int | None = None
small_holiday_free_minutes: int | None = None
small_holiday_basic_minutes: int | None = None
small_holiday_basic_fee: int | None = None
small_holiday_unit_minutes: int | None = None
small_holiday_unit_fee: int | None = None
small_holiday_daily_max_fee: int | None = None
large_holiday_free_minutes: int | None = None
large_holiday_basic_minutes: int | None = None
large_holiday_basic_fee: int | None = None
large_holiday_unit_minutes: int | None = None
large_holiday_unit_fee: int | None = None
large_holiday_daily_max_fee: int | None = None
raw: RawRecord = Field(default_factory=dict, repr=False)


Expand Down
70 changes: 61 additions & 9 deletions src/krairport/providers/kac.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,23 +574,75 @@ def _build_aircraft_assignment(
raise KrairportParseError(f"failed to parse KAC aircraft record: {exc}") from exc


def _parking_fee_integer(value: Any) -> int | None:
"""없는 값과 0을 구별하고 음수·소수 요금을 절삭하여 정상 요금으로 만들지 않는다."""
text = strip_or_none(value)
if text is None:
return None
if not text.isascii() or re.fullmatch(r"[0-9,]+", text) is None:
raise ValueError("주차요금 필드는 0 이상의 정수여야 합니다.")
if "," in text and re.fullmatch(r"[1-9][0-9]{0,2}(?:,[0-9]{3})+", text) is None:
return None
normalized = text.replace(",", "")
return int(normalized)


def _build_parking_fee(row: Mapping[str, Any], *, requested_airport_code: str | None) -> ParkingFee:
try:
airport_code = strip_or_none(first_value(row, "airportCode", "schAirportCode"))
return ParkingFee(
airport_code=airport_code or requested_airport_code or "",
parking_name=strip_or_none(
first_value(row, "parkingName", "parkingArea", "parkingDiv")
first_value(row, "parkingParkingName", "parkingName", "parkingArea", "parkingDiv")
),
small_basic_minutes=_parking_fee_integer(first_value(row, "parkingBasicM")),
small_basic_fee=_parking_fee_integer(first_value(row, "parkingBasicAccount")),
large_basic_minutes=_parking_fee_integer(first_value(row, "parkingBasicMd")),
large_basic_fee=_parking_fee_integer(first_value(row, "parkingBasicAccountd")),
small_daily_max_fee=_parking_fee_integer(
first_value(
row, "parkingMaxAccount", "parkingDayMaxAccount", "parkingOneDayAccount"
)
),
large_daily_max_fee=_parking_fee_integer(
first_value(
row, "parkingMaxAccountd", "parkingDayMaxAccountd", "parkingOneDayAccountd"
)
),
airport_name=strip_or_none(first_value(row, "siteName")),
small_free_minutes=_parking_fee_integer(first_value(row, "parkingFreeM")),
small_unit_minutes=_parking_fee_integer(first_value(row, "parkingMinuteM")),
small_unit_fee=_parking_fee_integer(first_value(row, "parkingMinuteAccount")),
large_free_minutes=_parking_fee_integer(first_value(row, "parkingFreeMd")),
large_unit_minutes=_parking_fee_integer(first_value(row, "parkingMinuteMd")),
large_unit_fee=_parking_fee_integer(first_value(row, "parkingMinuteAccountd")),
small_holiday_free_minutes=_parking_fee_integer(first_value(row, "parkingHoliFreeM")),
small_holiday_basic_minutes=_parking_fee_integer(first_value(row, "parkingHoliBasicM")),
small_holiday_basic_fee=_parking_fee_integer(
first_value(row, "parkingHoliBasicAccount")
),
small_holiday_unit_minutes=_parking_fee_integer(first_value(row, "parkingHoliMinuteM")),
small_holiday_unit_fee=_parking_fee_integer(
first_value(row, "parkingHoliMinuteAccount")
),
small_holiday_daily_max_fee=_parking_fee_integer(
first_value(row, "parkingHoliMaxAccount")
),
large_holiday_free_minutes=_parking_fee_integer(first_value(row, "parkingHoliFreeMd")),
large_holiday_basic_minutes=_parking_fee_integer(
first_value(row, "parkingHoliBasicMd")
),
large_holiday_basic_fee=_parking_fee_integer(
first_value(row, "parkingHoliBasicAccountd")
),
large_holiday_unit_minutes=_parking_fee_integer(
first_value(row, "parkingHoliMinuteMd")
),
small_basic_minutes=to_int_or_none(first_value(row, "parkingBasicM")),
small_basic_fee=to_int_or_none(first_value(row, "parkingBasicAccount")),
large_basic_minutes=to_int_or_none(first_value(row, "parkingBasicMd")),
large_basic_fee=to_int_or_none(first_value(row, "parkingBasicAccountd")),
small_daily_max_fee=to_int_or_none(
first_value(row, "parkingDayMaxAccount", "parkingOneDayAccount")
large_holiday_unit_fee=_parking_fee_integer(
first_value(row, "parkingHoliMinuteAccountd")
),
large_daily_max_fee=to_int_or_none(
first_value(row, "parkingDayMaxAccountd", "parkingOneDayAccountd")
large_holiday_daily_max_fee=_parking_fee_integer(
first_value(row, "parkingHoliMaxAccountd")
),
raw=dict(row),
)
Expand Down
105 changes: 105 additions & 0 deletions tests/test_parking_fee_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
from __future__ import annotations

from xml.sax.saxutils import escape

import pytest

from krairport.exceptions import KrairportParseError
from krairport.models import ParkingFee
from krairport.providers.kac import KacClient
from tests.conftest import FakeResponse, FakeSession


async def _read(fields: dict[str, str]) -> ParkingFee:
item = "".join(f"<{key}>{escape(value)}</{key}>" for key, value in fields.items())
xml = (
"<response><header><resultCode>00</resultCode></header><body><items><item>"
f"{item}</item></items></body></response>"
)
client = KacClient("OFFLINE_KEY", session=FakeSession([FakeResponse(text=xml)]), retries=0)
return (await client.parking_fees(airport_code="GMP"))[0]


async def test_fee_official_fields_and_vehicle_holiday_types_are_preserved() -> None:
fields = {
"siteName": "김포국제공항",
"parkingParkingName": "국제선 주차장",
"parkingName": "과거 별칭",
}
expected: dict[str, int] = {}
for vehicle, suffix in (("small", ""), ("large", "d")):
for holiday, provider_prefix in (("", "parking"), ("holiday_", "parkingHoli")):
for name, source, value in (
("basic_minutes", "BasicM", 30),
("basic_fee", "BasicAccount", 1000),
("free_minutes", "FreeM", 0),
("unit_minutes", "MinuteM", 15),
("unit_fee", "MinuteAccount", 500),
("daily_max_fee", "MaxAccount", 20000),
):
fields[provider_prefix + source + suffix] = f"{value:,}"
expected[f"{vehicle}_{holiday}{name}"] = value
fields["parkingDayMaxAccount"] = "99999"
result = await _read(fields)
assert result.airport_code == "GMP"
assert result.airport_name == "김포국제공항"
assert result.parking_name == "국제선 주차장"
for name, value in expected.items():
assert getattr(result, name) == value
assert result.raw == fields
assert ParkingFee.model_validate(result.model_dump(mode="json")) == result


async def test_missing_fees_remain_none_and_zero_is_not_replaced_by_alias() -> None:
result = await _read(
{
"parkingParkingName": "",
"parkingName": "과거 주차장",
"parkingMaxAccount": "0",
"parkingDayMaxAccount": "20000",
"parkingHoliBasicAccount": "-",
"parkingHoliMinuteM": " ",
}
)
assert result.parking_name == "과거 주차장"
assert result.small_daily_max_fee == 0
assert result.small_holiday_basic_fee is None
assert result.small_holiday_unit_minutes is None
assert result.large_holiday_daily_max_fee is None


@pytest.mark.parametrize("value", [
"1.5", "-1", "NaN", "Infinity", "invalid", "-1,000", "1,000.5", "invalid,", "NaN,",
])
async def test_invalid_fee_is_not_truncated_or_fabricated(value: str) -> None:
with pytest.raises(KrairportParseError):
await _read({"parkingHoliMinuteAccount": value})


async def test_old_public_constructor_remains_compatible() -> None:
result = ParkingFee(
airport_code="GMP",
parking_name=None,
small_basic_minutes=None,
small_basic_fee=None,
large_basic_minutes=None,
large_basic_fee=None,
small_daily_max_fee=None,
large_daily_max_fee=None,
)
assert result.small_holiday_basic_fee is None
assert result.airport_name is None


@pytest.mark.parametrize("value", ["1,5", ",1000", "1,,000", "1,000,", "0,000", "1234,000"])
async def test_malformed_thousands_separator_is_missing_not_a_guessed_fee(value: str) -> None:
result = await _read({"parkingMaxAccount": value, "parkingDayMaxAccount": "20000"})
assert result.small_daily_max_fee is None
assert result.raw["parkingMaxAccount"] == value


@pytest.mark.parametrize("value,expected", [("1,000", 1000), ("12,345,678", 12345678), ("000", 0)])
async def test_valid_thousands_and_unseparated_integer_are_preserved(
value: str, expected: int,
) -> None:
assert (await _read({"parkingMinuteAccount": value})).small_unit_fee == expected
Loading