Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
)
Expand Down Expand Up @@ -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)}개")
Expand Down
6 changes: 4 additions & 2 deletions backend/src/services/brand_mapping_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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})
Expand Down
3 changes: 2 additions & 1 deletion backend/src/services/commercial_intelligence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null | undefined;
Expand All @@ -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,
}));
}

Expand Down
47 changes: 43 additions & 4 deletions frontend/src/components/SimulationResult/sections/MarketMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -25,6 +27,8 @@ export interface SameBrandLocation {
lng: number;
dong_name?: string;
address?: string;
place_url?: string | null;
phone?: string | null;
}

export interface MarketMapProps {
Expand Down Expand Up @@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

function buildCompetitorInfoHtml(
c: Competitor,
radius: number,
Expand All @@ -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
? `<a href="${escapeHtml(c.place_url)}" target="_blank" rel="noopener noreferrer" style="font-size:13px;font-weight:600;color:#60a5fa;text-decoration:underline;">${escapeHtml(brand)}</a>`
: `<span style="font-size:13px;font-weight:600;">${escapeHtml(brand)}</span>`;
// 매장 상세 라인 — 본 매장 place_name (브랜드와 다를 때) + 전화번호.
const placeNameLine =
c.place_name && c.place_name !== brand
? `<div>매장: <span style="color:#f4f4f5;">${escapeHtml(c.place_name)}</span></div>`
: '';
const phoneLine = c.phone
? `<div>전화: <a href="tel:${escapeHtml(c.phone)}" style="color:#60a5fa;text-decoration:none;">${escapeHtml(c.phone)}</a></div>`
: '';
return `
<div style="font-family:Pretendard,ui-sans-serif,system-ui;min-width:180px;padding:10px 12px;background:rgba(24,24,27,0.95);color:#e4e4e7;border:1px solid #3f3f46;border-radius:6px;backdrop-filter:blur(8px);">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;background:${accent};"></span>
<span style="font-size:13px;font-weight:600;">${brand}</span>
${nameHtml}
</div>
<div style="font-size:11px;color:#a1a1aa;line-height:1.6;">
${placeNameLine}
<div>거리: <span style="color:#f4f4f5;">${formatDistance(distM)}</span></div>
<div>반경: <span style="color:${within ? '#fbbf24' : '#a1a1aa'};">${within ? '내부' : '외부'}</span></div>
<div>일매출 추정: <span style="color:#f4f4f5;">${formatKrwWan(c.daily_revenue)}</span></div>
${phoneLine}
</div>
</div>
`;
Expand Down Expand Up @@ -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
? `<a href="${escapeHtml(s.place_url)}" target="_blank" rel="noopener noreferrer" style="font-size:13px;font-weight:600;color:#fbbf24;text-decoration:underline;">${escapeHtml(brandName)}</a>`
: `<span style="font-size:13px;font-weight:600;">${escapeHtml(brandName)}</span>`;
const placeNameHtml = s.place_url
? `<a href="${escapeHtml(s.place_url)}" target="_blank" rel="noopener noreferrer" style="color:#60a5fa;text-decoration:underline;">${escapeHtml(s.place_name)}</a>`
: escapeHtml(s.place_name);
const phoneHtml = s.phone
? `<div>전화: <a href="tel:${escapeHtml(s.phone)}" style="color:#60a5fa;text-decoration:none;">${escapeHtml(s.phone)}</a></div>`
: '';
const iw = new maps.InfoWindow({
position: pos,
content: `<div style="font-family:Pretendard,ui-sans-serif,system-ui;min-width:180px;padding:10px 12px;background:rgba(24,24,27,0.95);color:#e4e4e7;border:1px solid #fbbf24;border-radius:6px;backdrop-filter:blur(8px);">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;background:#fbbf24;"></span>
<span style="font-size:13px;font-weight:600;">${s.brand_name || '자사매장'}</span>
${nameHtml}
</div>
<div style="font-size:11px;color:#a1a1aa;line-height:1.6;">
<div>${s.place_name}</div>
<div>${s.dong_name || ''} ${s.address || ''}</div>
<div>${placeNameHtml}</div>
<div>${escapeHtml(s.dong_name || '')} ${escapeHtml(s.address || '')}</div>
${phoneHtml}
</div>
</div>`,
removable: true,
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -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;
Expand Down