도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
더하면 어긋날 자리가 하나 더 생기기 때문이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
148 lines
6.4 KiB
Python
148 lines
6.4 KiB
Python
"""여행 일정(1박2일·2박3일) 생성 — 순수 함수 모듈. DB·HTTP 없음.
|
|
|
|
빌드(payload) 시점에 그 지역의 발행된 지역정보(관광지·맛집·축제)와 업체 좌표로
|
|
일정을 **즉석 계산**한다. 저장하지 않는 이유: 재료(local_contents)가 갱신되면
|
|
다음 빌드에서 일정도 저절로 최신이 된다 — 따로 저장하면 그 동기화를 또 만들어야 한다.
|
|
(docs/superpowers/specs/2026-09-03-local-tourapi-sync-design.md)
|
|
|
|
★ 지어내지 않는 규칙은 여기도 적용된다.
|
|
재료가 부족하면 채울 수 있는 만큼만 담고, 하루도 못 채우면 일정 자체를 내지 않는다.
|
|
좌표 없는 항목은 거리를 잴 수 없으므로 후보에서 뺀다 — 동선을 보장 못 하는 추천은 틀린 추천이다.
|
|
|
|
하루의 뼈대: 관광지 2 + 맛집 2(점심·저녁). 진행 중 축제가 있으면 그날 관광지 한 자리를 대신한다.
|
|
일자 배분은 업체에서 가까운 순으로 후보를 끊고, 일자 안에서는 최근접 이웃 순서로 동선을 만든다.
|
|
"""
|
|
from typing import Optional
|
|
|
|
from common.utils.geo import haversine_km
|
|
|
|
# 하루 구성 정원. 관광지 자리는 축제가 하나 대신할 수 있다.
|
|
_SPOTS_PER_DAY = 2
|
|
_MEALS_PER_DAY = 2
|
|
# 후보 반경(km). 업체에서 이보다 먼 곳은 '근처'가 아니다 — 1박2일 생활권을 넘는다.
|
|
_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 지역정보 행 → 거리 오름차순 후보. 좌표가 없거나 반경 밖이면 뺀다.
|
|
|
|
행 모양은 스냅샷 local.contents 의 body(services/external/tour_api._normalize)다 —
|
|
mapx=경도, mapy=위도 (WGS84 문자열).
|
|
"""
|
|
out = []
|
|
for row in rows:
|
|
body = row.get("body") or {}
|
|
name = str(row.get("title") or body.get("title") or "").strip()
|
|
lat, lng = _as_float(body.get("mapy")), _as_float(body.get("mapx"))
|
|
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: "셋째 날"}
|
|
# 며칠짜리인가 → 사람이 읽는 말. 화면은 이 값으로 일정을 가른다(ItineraryItem.duration).
|
|
_DURATION = {2: "1박 2일", 3: "2박 3일"}
|
|
|
|
|
|
def _to_stop(cand: dict) -> dict:
|
|
"""후보 → 렌더러의 PlannerStop.
|
|
|
|
★ 머무는 시간·이동 시간은 **비운다.** 우리가 재지 않은 값이라, 넣으면 화면의 시각표가
|
|
지어낸 숫자 위에 세워진다. 화면은 없으면 자기 기본값으로 계산한다.
|
|
★ URL 을 만들지 않는다 — 지도 검색어(searchQuery)만 준다(LocalPlace 와 같은 규약).
|
|
"""
|
|
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]:
|
|
"""일자별 계획. 첫날 하루도 못 채우면 None — 반쪽짜리 일정은 내지 않는다."""
|
|
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)의 모양으로 낸다. 예전에는 {days, plan[{day, stops}]}
|
|
# 라는 우리끼리의 모양이었고, 화면(ItinerarySection)은 그걸 못 읽어 **일정이 통째로
|
|
# 안 나왔다** — 수집·계산은 다 됐는데 화면만 비어 있었다(실측 2026-09-09, 조이모텔).
|
|
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일) 일정. 좌표가 없으면 빈 배열.
|
|
|
|
입력 행 모양은 스냅샷 local.contents 항목({title, body:{mapx, mapy, …}})이다.
|
|
"""
|
|
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
|