diff --git a/solution/backend/router/v1/local/protocol.py b/solution/backend/router/v1/local/protocol.py index 23d6519..b8a617d 100644 --- a/solution/backend/router/v1/local/protocol.py +++ b/solution/backend/router/v1/local/protocol.py @@ -83,6 +83,9 @@ class ResLocalGuide(Res_WebPacketProtocol): restaurants: list[dict[str, Any]] = [] festivals: list[dict[str, Any]] = [] courses: list[dict[str, Any]] = [] + # ★ 1박2일·2박3일 각 5개(services/itinerary_llm_service). 발행본 payload.local.itineraries 와 + # **같은 값**이다 — 캔버스가 다른 목록을 보이면 "미리보기와 다르다"가 된다. + itineraries: list[dict[str, Any]] = [] synced_at: str | None = None diff --git a/solution/backend/services/local_content_service.py b/solution/backend/services/local_content_service.py index 473ee1e..95c1143 100644 --- a/solution/backend/services/local_content_service.py +++ b/solution/backend/services/local_content_service.py @@ -363,7 +363,8 @@ class LocalContentService: ★ 스냅샷 필터(숨김·노출창)와 payload 변환을 **그대로 재사용**한다 — 캔버스가 발행본과 다른 목록을 보이면 사장님이 "미리보기와 다르다"고 읽는다. 그래서 여기서 DB 를 따로 읽지 않고 발행 파이프라인의 두 함수를 잇기만 한다. - ★ 일정(itineraries)은 payload 가 만들어도 여기선 내려보내지 않는다 — 캔버스에 그릴 자리가 아직 없다. + ★ 일정(itineraries)도 함께 내려보낸다 — 캔버스 `ItineraryTickets` 가 섹션 데이터가 비었을 때 + 이 값으로 떨어진다(발행본 ItinerarySection 과 같은 폴백). 예전에는 그릴 자리가 없어 뺐다. """ # 순환 import 회피 — snapshot·site_payload 는 발행 파이프라인 모듈이라 서비스 최상단에서 끌어오지 않는다. from services.site_payload import _local @@ -399,6 +400,7 @@ class LocalContentService: res.restaurants = local.get("restaurants") or [] res.festivals = local.get("festivals") or [] res.courses = local.get("courses") or [] + res.itineraries = local.get("itineraries") or [] res.synced_at = synced_at return res diff --git a/solution/backend/tests/test_local_guide_itinerary.py b/solution/backend/tests/test_local_guide_itinerary.py new file mode 100644 index 0000000..69d1e4f --- /dev/null +++ b/solution/backend/tests/test_local_guide_itinerary.py @@ -0,0 +1,76 @@ +"""캔버스 가이드가 일정을 함께 내려보내는가. + +★ 예전에는 일부러 뺐다 — "캔버스에 그릴 자리가 아직 없다"(get_guide docstring). + 자리를 만들었으므로(ItineraryTickets 의 폴백) 이제 실어 보낸다. + 빼 두면 캔버스와 발행본이 다른 목록을 보이고, 그건 이 함수 자신이 경계한 상황이다. +""" +import uuid + +from sqlalchemy import text + +from common.enums import PlaceCategory, SourceType + + +async def _seed_place(db_engine, owner_id, name="스테이,머뭄") -> str: + pid = uuid.uuid4() + async with db_engine.begin() as conn: + await conn.execute( + text("INSERT INTO places (place_id, owner_user_id, name, category, road_address, " + "region_code, latitude, longitude, status) " + "VALUES (:p, :o, :n, :c, :r, :rc, :lat, :lng, 1)"), + {"p": pid, "o": uuid.UUID(owner_id), "n": name, "c": PlaceCategory.LODGING.value, + "r": "전북특별자치도 군산시 절골길 18", "rc": "52군산시", + "lat": 35.98642, "lng": 126.70612}, + ) + return str(pid) + + +async def _seed_itinerary(db_engine, place_id, duration="1박 2일", course="원도심 코스"): + body = [{ + "name": course, "duration": duration, "audience": "처음 온 손님", "why": "이유.", + "days": [{"label": "첫째 날", "startTime": "14:00", + "stops": [{"name": "군산근대역사박물관", "minutes": 90, "moveMinutes": 15, + "searchQuery": "군산근대역사박물관", + "latitude": 35.9985, "longitude": 126.7107}]}], + "source": {"name": "군산문화관광", "url": "https://www.gunsan.go.kr/tour/"}, + }] + import json + async with db_engine.begin() as conn: + await conn.execute( + text("INSERT INTO place_itineraries (place_itinerary_id, place_id, duration, body, " + "generated_by, model, generated_at) " + "VALUES (:i, :p, :d, CAST(:b AS jsonb), :g, :m, now())"), + {"i": uuid.uuid4(), "p": uuid.UUID(place_id), "d": duration, + "b": json.dumps(body, ensure_ascii=False), + "g": SourceType.LLM.value, "m": "perplexity:sonar"}, + ) + + +async def test_guide_returns_stored_itineraries(client, db_engine, owner_id, monkeypatch): + """검증: 저장된 일정이 있는 업장의 캔버스 가이드. + 기대결과: itineraries 가 실려 온다 — 캔버스가 발행본과 같은 목록을 본다.""" + # 생성·수집은 이 테스트의 관심이 아니다. 유료 호출과 잡 등록을 막는다. + monkeypatch.setattr("services.llm.perplexity.is_configured", lambda: False) + + pid = await _seed_place(db_engine, owner_id) + await _seed_itinerary(db_engine, pid) + + body = (await client.get("/v1/local/guide", params={"place_id": pid})).json() + + assert body["result"]["success"] is True + assert [i["name"] for i in body["itineraries"]] == ["원도심 코스"] + assert body["itineraries"][0]["duration"] == "1박 2일" + assert body["itineraries"][0]["days"][0]["stops"][0]["name"] == "군산근대역사박물관" + + +async def test_guide_without_itineraries_returns_empty_list(client, db_engine, owner_id, monkeypatch): + """검증: 아직 생성되지 않은 업장. + 기대결과: 빈 배열 — 캔버스는 PasteHint 를 띄운다(에러가 아니다).""" + monkeypatch.setattr("services.llm.perplexity.is_configured", lambda: False) + + pid = await _seed_place(db_engine, owner_id) + + body = (await client.get("/v1/local/guide", params={"place_id": pid})).json() + + assert body["result"]["success"] is True + assert body.get("itineraries", []) == [] diff --git a/solution/frontend/src/features/builder/canvas/variants/itinerary/ItineraryTickets.tsx b/solution/frontend/src/features/builder/canvas/variants/itinerary/ItineraryTickets.tsx index 6f8f34f..eb6159f 100644 --- a/solution/frontend/src/features/builder/canvas/variants/itinerary/ItineraryTickets.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/itinerary/ItineraryTickets.tsx @@ -5,6 +5,8 @@ * ★ 순위를 매기지 않는다 — 어느 일정이 1위인지는 우리가 정할 일이 아니다. * ★ 시각은 사장님이 적는 게 아니라 **계산한다**. 출발 시각 + 이동 + 머무는 시간. * 출발을 당기면 하루가 통째로 밀린다. + * ★ 사장님이 붙여넣은 것이 없으면 **서버가 만든 일정**을 쓴다(useLocalGuide). 발행본 + * ItinerarySection 과 같은 폴백이다 — 둘이 갈리면 "미리보기와 다르다"가 된다. */ import { currentSeasons, @@ -15,6 +17,7 @@ import { type ItineraryItem, type PlannedStop, } from '@o2o/shared'; +import {useLocalGuide} from '@/hooks/useLocalGuide'; import {SectionBody, SectionFrame, Rail} from '../../primitives'; import type {SectionRenderProps} from '../../types'; import { @@ -32,11 +35,14 @@ import '../items/items.css'; export function ItineraryTickets(props: SectionRenderProps) { const {section, isSelected, onSelect} = props; const parsed = parseSectionData(section.type, section.data); + const guide = useLocalGuide(); + // 사장님이 적은 것이 언제나 이긴다 — 서버가 만든 건 비었을 때의 기본값이다. + const items = parsed.items.length > 0 ? parsed.items : guide.itineraries; const live = currentSeasons(); - const durations = itineraryDurations(parsed.items); + const durations = itineraryDurations(items); const groups: (string | undefined)[] = durations.length > 0 - ? [...durations, ...(parsed.items.some((i) => !i.duration?.trim()) ? [undefined] : [])] + ? [...durations, ...(items.some((i) => !i.duration?.trim()) ? [undefined] : [])] : [undefined]; return ( @@ -55,17 +61,17 @@ export function ItineraryTickets(props: SectionRenderProps) { {parsed.error ? ( - ) : parsed.items.length === 0 ? ( + ) : items.length === 0 ? ( ) : (
{groups.map((duration) => { - const items = parsed.items.filter((item) => + const dayItems = items.filter((item) => duration === undefined ? !item.duration?.trim() : item.duration?.trim() === duration, ); - if (items.length === 0) return null; + if (dayItems.length === 0) return null; return (
{duration && ( @@ -73,7 +79,7 @@ export function ItineraryTickets(props: SectionRenderProps) { {duration} )} - {items.map((item, index) => ( + {dayItems.map((item, index) => (

diff --git a/solution/frontend/src/hooks/useLocalGuide.ts b/solution/frontend/src/hooks/useLocalGuide.ts index eacd6fe..3226d47 100644 --- a/solution/frontend/src/hooks/useLocalGuide.ts +++ b/solution/frontend/src/hooks/useLocalGuide.ts @@ -1,4 +1,5 @@ import {useEffect, useState} from 'react'; +import type {ItineraryItem} from '@o2o/shared'; import type {FestivalCard, GuideCard} from '@/features/builder/canvas/variants/local/types'; import {useBuilderStore} from '@/stores/builder'; @@ -6,11 +7,16 @@ export interface LocalGuideData { foods: GuideCard[]; spots: GuideCard[]; festivals: FestivalCard[]; + /** + * 서버가 만든 추천 일정(1박2일·2박3일 각 5개). **모양을 바꾸지 않는다** — + * 맛집·명소와 달리 이미 렌더러 계약(ItineraryItem)이라 toCard 를 거치지 않는다. + */ + itineraries: ItineraryItem[]; /** 서버가 가장 최근에 수집한 시각(ISO). 아직 아무것도 없으면 undefined. */ syncedAt?: string; } -const EMPTY: LocalGuideData = {foods: [], spots: [], festivals: []}; +const EMPTY: LocalGuideData = {foods: [], spots: [], festivals: [], itineraries: []}; // 종류별 노출 상한. 서버(snapshot._LOCAL_MAX_PER_TYPE)가 같은 수로 자르지만, 화면이 먼저 넘치지 않게 여기서도 막는다. const MAX_PER_TYPE = 20; @@ -91,6 +97,7 @@ export function useLocalGuide(): LocalGuideData { foods: ((data.restaurants ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard), spots: ((data.attractions ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard), festivals: ((data.festivals ?? []) as GuideFestival[]).slice(0, MAX_PER_TYPE).map(toFestival), + itineraries: Array.isArray(data.itineraries) ? data.itineraries : [], syncedAt: data.synced_at ?? undefined, }; // ★ 빈 응답은 캐시하지 않는다 — 수집 전에 열어둔 화면이 빈 결과를 물고 있으면