o2o-site-AEO/solution/backend/services/itinerary.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

121 lines
4.1 KiB
Python

"""여행 일정(1박2일·2박3일) 생성 — 순수 함수 모듈."""
from typing import Optional
from common.utils.geo import haversine_km
# 하루 구성 정원.
_SPOTS_PER_DAY = 2
_MEALS_PER_DAY = 2
# 후보 반경(km).
_MAX_RADIUS_KM = 30.0
_STOP_ATTRACTION = "attraction"
_STOP_RESTAURANT = "restaurant"
_STOP_FESTIVAL = "festival"
def _as_float(value) -> Optional[float]:
try:
return float(value)
except (TypeError, ValueError):
return None
def _candidates(rows: list[dict], stop_type: str,
base_lat: float, base_lng: float) -> list[dict]:
"""payload 지역정보 행 → 거리 오름차순 후보."""
out = []
for row in rows:
body = row.get("body") or {}
name = str(body.get("name") or row.get("title") or "").strip()
lat, lng = _as_float(row.get("latitude")), _as_float(row.get("longitude"))
if not name or lat is None or lng is None:
continue
dist = haversine_km(base_lat, base_lng, lat, lng)
if dist > _MAX_RADIUS_KM:
continue
out.append({"type": stop_type, "name": name, "lat": lat, "lng": lng,
"distanceKm": round(dist, 1)})
out.sort(key=lambda c: c["distanceKm"])
return out
def _order_by_route(stops: list[dict], base_lat: float, base_lng: float) -> list[dict]:
"""일자 안 동선: 업체에서 출발해 최근접 이웃 순으로 잇는다."""
remaining = list(stops)
ordered: list[dict] = []
lat, lng = base_lat, base_lng
while remaining:
nxt = min(remaining, key=lambda s: haversine_km(lat, lng, s["lat"], s["lng"]))
remaining.remove(nxt)
ordered.append(nxt)
lat, lng = nxt["lat"], nxt["lng"]
return ordered
def _take(pool: list[dict], count: int) -> list[dict]:
taken, pool[:] = pool[:count], pool[count:]
return taken
# 일자 이름.
_DAY_LABEL = {1: "첫째 날", 2: "둘째 날", 3: "셋째 날"}
# 며칠짜리인가 → 사람이 읽는 말.
_DURATION = {2: "1박 2일", 3: "2박 3일"}
def _to_stop(cand: dict) -> dict:
"""후보 → 렌더러의 PlannerStop."""
return {
"name": cand["name"],
"searchQuery": cand["name"],
"latitude": cand["lat"],
"longitude": cand["lng"],
}
def _plan_days(days: int, attractions: list[dict], restaurants: list[dict],
festivals: list[dict], base_lat: float, base_lng: float) -> Optional[dict]:
"""일자별 계획."""
spots = list(attractions)
meals = list(restaurants)
fests = list(festivals)
plan = []
for day in range(1, days + 1):
day_stops = []
# 축제는 하루 하나까지, 관광지 한 자리를 대신한다.
fest = _take(fests, 1)
day_stops += fest
day_stops += _take(spots, _SPOTS_PER_DAY - len(fest))
day_stops += _take(meals, _MEALS_PER_DAY)
if not day_stops:
break
plan.append({
"label": _DAY_LABEL.get(day, f"{day}일차"),
"stops": [_to_stop(s) for s in _order_by_route(day_stops, base_lat, base_lng)],
})
if not plan:
return None
# 렌더러 계약(`shared` ItineraryItem)의 모양으로 낸다.
return {"name": _DURATION[days], "duration": _DURATION[days], "days": plan}
def build_itineraries(base_lat: Optional[float], base_lng: Optional[float],
attractions: list[dict], restaurants: list[dict],
festivals: list[dict]) -> list[dict]:
"""업체 좌표 기준 1박2일(2일)·2박3일(3일) 일정."""
if base_lat is None or base_lng is None:
return []
spot_pool = _candidates(attractions, _STOP_ATTRACTION, base_lat, base_lng)
meal_pool = _candidates(restaurants, _STOP_RESTAURANT, base_lat, base_lng)
fest_pool = _candidates(festivals, _STOP_FESTIVAL, base_lat, base_lng)
out = []
for days in (2, 3):
# 세트마다 독립된 풀 복사 — 1박2일이 소비한 후보가 2박3일에서 빠지면 안 된다.
built = _plan_days(days, list(spot_pool), list(meal_pool), list(fest_pool),
base_lat, base_lng)
if built:
out.append(built)
return out