From ac488348a8348593b319263a55002f6909e70c00 Mon Sep 17 00:00:00 2001 From: bat1120 Date: Mon, 4 May 2026 23:16:55 +0900 Subject: [PATCH 1/2] =?UTF-8?q?IM3:=20=EA=B2=BD=EC=9F=81=EC=97=85=EC=B2=B4?= =?UTF-8?q?=20InfoWindow=20=EC=B9=B4=EC=B9=B4=EC=98=A4=EB=A7=B5=20link=20+?= =?UTF-8?q?=20=EC=A0=84=ED=99=94=EB=B2=88=ED=98=B8=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상권분석 페이지 경쟁업체 마커 클릭 시 InfoWindow 에서 매장명을 카카오맵 place 페이지(http://place.map.kakao.com/{kakao_id}) 로 연결. 전화번호도 함께 노출 (tel: 링크). 변경: - backend/src/services/commercial_intelligence.py analyze_competition SQL 의 SELECT 에 place_url, phone 컬럼 추가. samples dict 에 두 필드 자동 포함 (기존 row_dict 패턴). - backend/src/main.py _collect_all_competitor_locations 결과 dict 에 place_url, phone 추가. schemas/simulation_output.py 의 all_competitor_locations 는 list[dict] 이라 별도 schema 변경 불필요 (extra='allow'). - frontend/src/types/index.ts SimulationOutput.all_competitor_locations 항목에 place_url, phone, category 옵셔널 필드 추가. - frontend/.../MarketMap.tsx Competitor interface 확장 (place_url, phone). buildCompetitorInfoHtml: place_url 있으면 매장명을 a 태그로 감싸 target=_blank 새 창. phone 있으면 tel: 링크. escapeHtml 추가. - frontend/.../MapSection.tsx buildCompetitors 의 두 분기 (all_competitor_locations / competition_500m) 모두에서 place_url, phone 패스스루. 검증: - ruff format 통과 (commercial_intelligence.py) - prettier 통과 (3 frontend 파일) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/main.py | 2 ++ .../src/services/commercial_intelligence.py | 3 ++- .../SimulationResult/sections/MapSection.tsx | 4 +++ .../SimulationResult/sections/MarketMap.tsx | 27 ++++++++++++++++++- frontend/src/types/index.ts | 3 +++ 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/backend/src/main.py b/backend/src/main.py index a0185800..2951abf5 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, } ) 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..edb923a2 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 { @@ -185,6 +187,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 +208,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}
`; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 654b3589..c48380d4 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<{ From 721817df3da032a7c103bfd8b90789bcf78cf581 Mon Sep 17 00:00:00 2001 From: bat1120 Date: Mon, 4 May 2026 23:32:54 +0900 Subject: [PATCH 2/2] =?UTF-8?q?IM3:=20=EC=9E=90=EC=82=AC=20=EB=A7=A4?= =?UTF-8?q?=EC=9E=A5=20InfoWindow=20=EB=8F=84=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=EB=A7=B5=20link=20+=20=EC=A0=84=ED=99=94=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전 commit (ac488348) 이 경쟁업체 (all_competitor_locations) 만 처리했고, 자사 매장 (same_brand_locations) 측 누락. 두 흐름이 별개 SQL/dict 변환 경로라 자사 측도 같은 패턴으로 보강. 변경: - backend/src/services/brand_mapping_resolver.py get_all_mapo_stores_by_brand SQL SELECT 에 place_url, phone 추가. - backend/src/main.py _collect_same_brand_locations 결과 dict 에 place_url, phone 추가. - frontend/src/types/index.ts SimulationOutput.same_brand_locations 항목에 place_url, phone 옵셔널 추가. - frontend/.../MarketMap.tsx SameBrandLocation interface 확장. 자사 매장 InfoWindow: place_url 있으면 brand_name + place_name 둘 다 카카오맵 새 창 link 로 표시. phone 있으면 tel: 링크. 경쟁업체 InfoWindow 와 동일 패턴. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/main.py | 2 ++ .../src/services/brand_mapping_resolver.py | 6 ++++-- .../SimulationResult/sections/MarketMap.tsx | 20 ++++++++++++++++--- frontend/src/types/index.ts | 2 ++ 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/backend/src/main.py b/backend/src/main.py index 2951abf5..53bd80a5 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -392,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/frontend/src/components/SimulationResult/sections/MarketMap.tsx b/frontend/src/components/SimulationResult/sections/MarketMap.tsx index edb923a2..322fa55c 100644 --- a/frontend/src/components/SimulationResult/sections/MarketMap.tsx +++ b/frontend/src/components/SimulationResult/sections/MarketMap.tsx @@ -27,6 +27,8 @@ export interface SameBrandLocation { lng: number; dong_name?: string; address?: string; + place_url?: string | null; + phone?: string | null; } export interface MarketMapProps { @@ -439,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 + ? `` + : ''; 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 c48380d4..61616da7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -431,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;