diff --git a/backend/src/main.py b/backend/src/main.py index a0185800..53bd80a5 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -329,6 +329,8 @@ async def _fetch_one(dong_name: str): "distance_m": s.get("distance_m"), "is_franchise": s.get("is_franchise", False), "category": s.get("category", ""), + "place_url": s.get("place_url"), + "phone": s.get("phone"), "source_dong": dong_name, } ) @@ -390,6 +392,8 @@ async def _collect_same_brand_locations( "lng": lon_v, "dong_name": s.get("dong_name", ""), "address": s.get("address", ""), + "place_url": s.get("place_url"), + "phone": s.get("phone"), } ) print(f"[same_brand] 4동({','.join(districts)}) 안 자사 매장 {len(results)}개") diff --git a/backend/src/services/brand_mapping_resolver.py b/backend/src/services/brand_mapping_resolver.py index 5851f320..7454954c 100644 --- a/backend/src/services/brand_mapping_resolver.py +++ b/backend/src/services/brand_mapping_resolver.py @@ -125,7 +125,8 @@ def get_all_mapo_stores_by_brand(brand_name: str) -> list[dict]: dong_name NULL 인 매장은 제외. Returns: - [{kakao_id, place_name, brand_name, lat, lon, dong_name, address}, ...] + [{kakao_id, place_name, brand_name, lat, lon, dong_name, address, + place_url, phone}, ...] """ # 0차: DB 기반 canonical resolve (biz_brand_mapping 5,900개 활용) resolved = resolve_brand_name(brand_name) or brand_name @@ -137,7 +138,8 @@ def get_all_mapo_stores_by_brand(brand_name: str) -> list[dict]: conditions = " OR ".join(f"brand_name ILIKE :a{i}" for i in range(len(aliases))) sql = text( f""" - SELECT kakao_id, place_name, brand_name, lat, lon, dong_name, address + SELECT kakao_id, place_name, brand_name, lat, lon, dong_name, address, + place_url, phone FROM kakao_store WHERE dong_name IS NOT NULL AND ({conditions}) diff --git a/backend/src/services/commercial_intelligence.py b/backend/src/services/commercial_intelligence.py index 96578976..516da3b8 100644 --- a/backend/src/services/commercial_intelligence.py +++ b/backend/src/services/commercial_intelligence.py @@ -114,7 +114,8 @@ def analyze_competition( sql = text( """ SELECT kakao_id, place_name, brand_name, category, - lat, lon, is_franchise + lat, lon, is_franchise, + place_url, phone FROM kakao_store WHERE lat BETWEEN :lat_min AND :lat_max AND lon BETWEEN :lon_min AND :lon_max diff --git a/frontend/src/components/SimulationResult/sections/MapSection.tsx b/frontend/src/components/SimulationResult/sections/MapSection.tsx index b73f14ae..ec4f7b5a 100644 --- a/frontend/src/components/SimulationResult/sections/MapSection.tsx +++ b/frontend/src/components/SimulationResult/sections/MapSection.tsx @@ -45,6 +45,8 @@ function buildCompetitors(simResult: SimulationOutput): Competitor[] { is_franchise: s.is_franchise ?? false, brand_name: s.brand_name ?? null, daily_revenue: null, + place_url: s.place_url ?? null, + phone: s.phone ?? null, })); } const compIntel = simResult.competitor_intel as Record | null | undefined; @@ -70,6 +72,8 @@ function buildCompetitors(simResult: SimulationOutput): Competitor[] { : typeof s.est_daily_revenue === 'number' ? s.est_daily_revenue : null, + place_url: typeof s.place_url === 'string' ? s.place_url : null, + phone: typeof s.phone === 'string' ? s.phone : null, })); } diff --git a/frontend/src/components/SimulationResult/sections/MarketMap.tsx b/frontend/src/components/SimulationResult/sections/MarketMap.tsx index f1ccd7d0..322fa55c 100644 --- a/frontend/src/components/SimulationResult/sections/MarketMap.tsx +++ b/frontend/src/components/SimulationResult/sections/MarketMap.tsx @@ -9,6 +9,8 @@ export interface Competitor { is_franchise?: boolean; brand_name?: string | null; daily_revenue?: number | null; + place_url?: string | null; + phone?: string | null; } export interface RankingEntry { @@ -25,6 +27,8 @@ export interface SameBrandLocation { lng: number; dong_name?: string; address?: string; + place_url?: string | null; + phone?: string | null; } export interface MarketMapProps { @@ -185,6 +189,15 @@ function formatKrwWan(v?: number | null): string { return `${Math.round(v / 10000).toLocaleString()}만원/일`; } +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function buildCompetitorInfoHtml( c: Competitor, radius: number, @@ -197,16 +210,30 @@ function buildCompetitorInfoHtml( const within = distM <= radius; const accent = within ? '#f59e0b' : '#71717a'; const brand = c.brand_name || c.place_name || '경쟁점'; + // 매장명 표시 — place_url 있으면 카카오맵 새 창 link, 없으면 plain text. + const nameHtml = c.place_url + ? `${escapeHtml(brand)}` + : `${escapeHtml(brand)}`; + // 매장 상세 라인 — 본 매장 place_name (브랜드와 다를 때) + 전화번호. + const placeNameLine = + c.place_name && c.place_name !== brand + ? `
매장: ${escapeHtml(c.place_name)}
` + : ''; + const phoneLine = c.phone + ? `
전화: ${escapeHtml(c.phone)}
` + : ''; return `
- ${brand} + ${nameHtml}
+ ${placeNameLine}
거리: ${formatDistance(distM)}
반경: ${within ? '내부' : '외부'}
일매출 추정: ${formatKrwWan(c.daily_revenue)}
+ ${phoneLine}
`; @@ -414,16 +441,28 @@ export function MarketMap({ logo.addEventListener('click', (ev) => { ev.stopPropagation(); if (infoWindowRef.current) infoWindowRef.current.close(); + const brandName = s.brand_name || '자사매장'; + // place_url 있으면 매장명을 카카오맵 link 로. 없으면 plain text. + const nameHtml = s.place_url + ? `${escapeHtml(brandName)}` + : `${escapeHtml(brandName)}`; + const placeNameHtml = s.place_url + ? `${escapeHtml(s.place_name)}` + : escapeHtml(s.place_name); + const phoneHtml = s.phone + ? `
전화: ${escapeHtml(s.phone)}
` + : ''; const iw = new maps.InfoWindow({ position: pos, content: `
- ${s.brand_name || '자사매장'} + ${nameHtml}
-
${s.place_name}
-
${s.dong_name || ''} ${s.address || ''}
+
${placeNameHtml}
+
${escapeHtml(s.dong_name || '')} ${escapeHtml(s.address || '')}
+ ${phoneHtml}
`, removable: true, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 654b3589..61616da7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -418,6 +418,9 @@ export interface SimulationOutput { distance_m?: number; is_franchise?: boolean; source_dong?: string; + place_url?: string; + phone?: string; + category?: string; }>; // winner + top3 4동 안 자사 브랜드 매장 좌표 (로고 아이콘 마커 + 영업구역 반경 원 표시용) same_brand_locations?: Array<{ @@ -428,6 +431,8 @@ export interface SimulationOutput { lng: number; dong_name?: string; address?: string; + place_url?: string; + phone?: string; }>; // [customer_revenue] 타겟 고객 매출 분석 (스펙: dict | None) customer_segment?: CustomerSegment | null;