"""여행 일정(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