From 96d4ba8fb1b0ac1e3705bae412b69b60ac05c0ec Mon Sep 17 00:00:00 2001 From: yejin Date: Wed, 6 May 2026 11:50:36 +0900 Subject: [PATCH 01/14] =?UTF-8?q?fix(map):=20=EA=B3=B5=EC=8B=A4=20spot=201?= =?UTF-8?q?~4=EC=9C=84=20=ED=95=AD=EC=83=81=204=EA=B0=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=9E=A5=20=E2=80=94=20winner+top3=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=9B=84=EB=B3=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: winner 동(예: 망원2동) 안 vacancy_spot 0건 시 1~4위 자리 비어 1순위 펄싱 핀만 (그것도 동 centroid 가짜 위치) 표시되던 회귀. 원인: buildBestVacancies 가 winner 동만 필터 → spot 부족 시 fallback 없음. 수정: winner 동 spot 우선 (backend score) + 부족분은 top3 동 spot 으로 채움 (listing_count 정렬). 항상 최대 4개 반환, 50m 근접 dedup 유지. - winnerSorted = winner 동 spot, score 정렬 - top3Sorted = top3 동 spot, listing_count 정렬 (score 없음) - merged → dedup → top 4 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SimulationResult/sections/MapSection.tsx | 68 ++++++++++++------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/SimulationResult/sections/MapSection.tsx b/frontend/src/components/SimulationResult/sections/MapSection.tsx index 6f2822bd..b1d0d492 100644 --- a/frontend/src/components/SimulationResult/sections/MapSection.tsx +++ b/frontend/src/components/SimulationResult/sections/MapSection.tsx @@ -120,46 +120,64 @@ interface BestVacancy { competitorCount500m: number | null; } -// 추천 동(winner_district) 내 공실 중 score 상위 4개 spot 반환. -// 기준: backend 가 spot 단위 score 를 부여한 경우(0~100, 경쟁밀도 0.45 + 지하철 접근성 0.35 + 매물 활성도 0.20 가중합). -// score 가 없으면 listing_count 최대 fallback (구버전 응답 호환). +// 추천 동(winner_district + top3) 내 공실 중 score 상위 4개 spot 반환. +// 기준: winner 동 spot 우선 (backend 가 score 부여) → 부족 시 top3 동 spot 으로 채움 (listing_count 정렬). +// 2026-05-06: winner 동 spot 0건이거나 부족할 때 1~4위 자리 비는 회귀 차단. +// 사용자 보고 "망원2동(winner) 매물 0건이라 spot 1위만 보임" → top3 spot 으로 4개 채움. // 1순위가 펄싱 핀 + 반경원 중심. 2~4순위는 번호 라벨 핀으로 비교 표시. function buildBestVacancies(simResult: SimulationOutput): BestVacancy[] { const sim = simResult as SimulationOutput & Record; const winner = (sim.winner_district ?? sim.target_district) as string | undefined; if (!winner) return []; + const top3 = Array.isArray(sim.top_3_candidates) + ? (sim.top_3_candidates as string[]).filter((d): d is string => typeof d === 'string') + : []; const spots = (sim.vacancy_spots as VacancySpotRaw[] | undefined) ?? []; - const sorted = spots + + const toCandidate = (s: VacancySpotRaw) => ({ + lat: s.lat as number, + lng: s.lon as number, + listingCount: typeof s.listing_count === 'number' ? s.listing_count : 0, + dongName: String(s.dong_name), + score: typeof s.score === 'number' ? s.score : null, + subwayDistanceM: typeof s.subway_distance_m === 'number' ? s.subway_distance_m : null, + competitorCount500m: + typeof s.competitor_count_500m === 'number' ? s.competitor_count_500m : null, + }); + + const validSpots = spots.filter( + (s) => + typeof s.lat === 'number' && + typeof s.lon === 'number' && + Number.isFinite(s.lat) && + Number.isFinite(s.lon), + ); + + // 1순위 후보군: winner 동 spot (backend score 부여됨) + const winnerSorted = validSpots .filter((s) => s.dong_name === winner) - .filter( - (s) => - typeof s.lat === 'number' && - typeof s.lon === 'number' && - Number.isFinite(s.lat) && - Number.isFinite(s.lon), - ) - .map((s) => ({ - lat: s.lat as number, - lng: s.lon as number, - listingCount: typeof s.listing_count === 'number' ? s.listing_count : 0, - dongName: String(s.dong_name), - score: typeof s.score === 'number' ? s.score : null, - subwayDistanceM: typeof s.subway_distance_m === 'number' ? s.subway_distance_m : null, - competitorCount500m: - typeof s.competitor_count_500m === 'number' ? s.competitor_count_500m : null, - })) + .map(toCandidate) .sort((a, b) => { const sa = a.score ?? Number.NEGATIVE_INFINITY; const sb = b.score ?? Number.NEGATIVE_INFINITY; if (sa !== sb) return sb - sa; return b.listingCount - a.listingCount; }); - // 근접 중복 제거 — 같은 매물군이 다른 row 로 들어와 1·2·3위가 동일 좌표인 케이스 방어. - // 50m 이내는 동일 spot 으로 보고 상위 score 만 유지 → 화면에서 #1 펄싱핀에 #2·#3 핀이 - // 가려지는 회귀 차단 (사용자 보고: "공실 #1 과 #4만 보인다"). + + // 2순위 후보군: top3 동 spot (score 없음, listing_count 정렬) + const top3Set = new Set(top3); + const top3Sorted = validSpots + .filter((s) => top3Set.has(String(s.dong_name)) && s.dong_name !== winner) + .map(toCandidate) + .sort((a, b) => b.listingCount - a.listingCount); + + // winner 부족분만큼 top3 동 spot 으로 채움 + const merged = [...winnerSorted, ...top3Sorted]; + + // 근접 중복 제거 — 50m 이내는 동일 spot 으로 보고 상위 우선 유지. const DEDUP_RADIUS_M = 50; const deduped: BestVacancy[] = []; - for (const cand of sorted) { + for (const cand of merged) { const tooClose = deduped.some( (kept) => haversineM(kept.lat, kept.lng, cand.lat, cand.lng) <= DEDUP_RADIUS_M, ); From 66b874e7601869381ba4dfa14f53bf1bba698d6e Mon Sep 17 00:00:00 2001 From: bat1120 Date: Wed, 6 May 2026 12:03:48 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat(corp):=20=EC=82=AC=EC=97=85=EC=9E=90?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EA=B8=B0=EB=B0=98=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EC=97=85=EC=A2=85=20dropdown=20=EC=9E=90=EB=8F=99=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/src/services/corp_brand_resolver.py: users.company_name → ftc_brand_franchise.corpNm 매칭, get_corp_industries / resolve_brand_for_industry - backend/src/main.py: GET /corp/operated-industries (JWT 기반 자동 추출), _validate_and_resolve_brand 헬퍼 + 7 endpoint Depends(get_optional_user) 통합 - backend/src/schemas/simulation_input.py: biz_number optional 필드 - frontend/src/api/client.ts: getOperatedIndustries() + OperatedIndustriesResponse 타입 - frontend/src/App.tsx: mount 시 fetch + 운영 외 frontend 라벨 disable + line-through + click toast 다업종 corp ((주)더본코리아 8업종 27 brand 등) 시 운영 외 업종 dropdown 차단. 비회원/CORP_NOT_IN_FTC: industries=null 반환 → 모든 업종 허용 (graceful degrade). Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/main.py | 187 ++++++++++++++++++-- backend/src/schemas/simulation_input.py | 7 + backend/src/services/corp_brand_resolver.py | 150 ++++++++++++++++ frontend/src/App.tsx | 83 +++++++-- frontend/src/api/client.ts | 24 +++ 5 files changed, 422 insertions(+), 29 deletions(-) create mode 100644 backend/src/services/corp_brand_resolver.py diff --git a/backend/src/main.py b/backend/src/main.py index 2da51076..e4a8a2f4 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -47,7 +47,7 @@ # LangSmith 트레이싱: langchain import 전에 os.environ 주입 필수 # (langchain SDK는 import 시점에 LANGCHAIN_TRACING_V2를 읽으므로 순서가 중요) from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException, Request, Response +from fastapi import Depends, FastAPI, HTTPException, Request, Response from fastapi.concurrency import run_in_threadpool from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -77,6 +77,7 @@ from src.schemas.simulation_input import SimulationInput from src.services.auth import AuthService from src.services.biz_mapper import BizMapper +from src.services.jwt_auth import UserContext, get_optional_user from models.explainability.shap_analysis import explain_tcn_prediction from models.explainability.simulation import ( @@ -225,6 +226,70 @@ def _pipeline_key(input_data: Any) -> str: return f"{input_data.target_district}:{input_data.business_type}:{input_data.brand_name}:{rent}:{area}:{radius}:{pop_w}" +def _resolve_user_biz_number(user: UserContext | None) -> str | None: + """JWT user → users.biz_number 조회. master 는 본인, manager 는 owner 의 biz_number.""" + if user is None: + return None + target_id = user.owner_id if user.role == "manager" else user.user_id + if not target_id: + return None + try: + import sqlalchemy as sa + + engine = sa.create_engine(settings.postgres_url) + with engine.connect() as conn: + row = conn.execute( + sa.text("SELECT biz_number FROM users WHERE id = :id"), + {"id": target_id}, + ).first() + return row._mapping["biz_number"] if row else None + except Exception as ex: + logger.warning(f"[brand_resolver] biz_number 조회 실패: {ex}") + return None + + +def _validate_and_resolve_brand( + input_data: SimulationInput, + current_user: UserContext | None = None, +) -> None: + """biz_number 입력 시 corp 검증 + 다업종 corp 의 brand auto-resolve. + + biz_number 우선순위: + 1. ``input_data.biz_number`` (frontend 명시 입력) + 2. JWT ``current_user`` 토큰에서 자동 추출 (master.user_id 또는 manager.owner_id) + 3. 없으면 검증 skip (개인사업자 / 비회원 호환) + + 동작: + 1. business_type 이 사용자 corp 의 운영 업종인지 검증. + 2. 운영 외 업종 → HTTPException(400) + 운영 가능 업종 list 응답. + 3. 운영 내 업종 + corp 의 해당 업종 brand 가 다른 brand 면 brand_name override. + """ + biz_number = input_data.biz_number or _resolve_user_biz_number(current_user) + if not biz_number: + return + + from src.services.corp_brand_resolver import resolve_brand_for_industry + + result = resolve_brand_for_industry(biz_number, input_data.business_type) + + if result.get("error") == "INDUSTRY_NOT_OPERATED": + raise HTTPException(status_code=400, detail=result) + + if result.get("error") in {"USER_NOT_FOUND", "CORP_NOT_IN_FTC", "INVALID_COMPANY_NAME"}: + # 비회원 / FTC 미등록 → 검증 skip, 사용자 brand_name 그대로 + logger.warning(f"[brand_resolver] {result['error']} biz={biz_number} — fallback to input.brand_name") + return + + # 성공: brand_name override (사용자가 다른 brand 입력했어도 corp 정합 brand 로 교체) + resolved_brand = result["brand_name"] + if input_data.brand_name != resolved_brand: + logger.info( + f"[brand_resolver] auto-resolve: input.brand_name='{input_data.brand_name}' → '{resolved_brand}' " + f"(corp={result['company_name']}, industry={input_data.business_type})" + ) + input_data.brand_name = resolved_brand + + _BIZ_TYPE_NORMALIZE: dict[str, str] = { "cafe": "카페", "coffee": "카페", @@ -930,7 +995,11 @@ async def get_status(job_id: str): @app.post("/analyze") -async def analyze_location(input_data: SimulationInput, response: Response): +async def analyze_location( + input_data: SimulationInput, + response: Response, + current_user: UserContext | None = Depends(get_optional_user), +): """[DEPRECATED] 풀파이프 상권 분석 — 전환 기간 동안만 유지. IM3-259로 endpoint를 분리(/predict + /analyze/llm)했으므로 신규 호출은 @@ -943,6 +1012,9 @@ async def analyze_location(input_data: SimulationInput, response: Response): response.headers["Deprecation"] = "true" response.headers["Link"] = '; rel="successor-version", ; rel="successor-version"' + # corp 다업종 brand auto-resolve + 운영 외 업종 차단 (biz_number 입력 시만) + _validate_and_resolve_brand(input_data, current_user) + if input_data.target_district not in MAPO_DISTRICTS: return { "status": "error", @@ -970,7 +1042,9 @@ async def analyze_location(input_data: SimulationInput, response: Response): result["all_competitor_locations"] = await _collect_all_competitor_locations( winner, top3, input_data.business_type ) - result["same_brand_locations"] = await _collect_same_brand_locations(winner, top3, input_data.brand_name, input_data.business_type) + result["same_brand_locations"] = await _collect_same_brand_locations( + winner, top3, input_data.brand_name, input_data.business_type + ) return {"status": "success", "data": result} except Exception as e: print(f"!!! [API ERROR] !!! {str(e)}") @@ -987,7 +1061,10 @@ async def analyze_location(input_data: SimulationInput, response: Response): @app.post("/analyze/llm") -async def analyze_llm(input_data: SimulationInput): +async def analyze_llm( + input_data: SimulationInput, + current_user: UserContext | None = Depends(get_optional_user), +): """AI 분석 전용 endpoint — slow_graph 실행 (~80-140초). /predict와 독립 병렬 호출 가능. winner는 ranking 단계에서 자체 결정. @@ -995,6 +1072,9 @@ async def analyze_llm(input_data: SimulationInput): from src.config.constants import MAPO_DISTRICTS from src.schemas.simulation_output import AnalysisOutput + # corp 다업종 brand auto-resolve + 운영 외 업종 차단 + _validate_and_resolve_brand(input_data, current_user) + if input_data.target_district not in MAPO_DISTRICTS: return { "status": "error", @@ -1035,7 +1115,9 @@ async def analyze_llm(input_data: SimulationInput): print(f"[ANALYZE/LLM] all_competitor_locations 수집 실패 (무시): {e}") full["all_competitor_locations"] = [] try: - full["same_brand_locations"] = await _collect_same_brand_locations(winner, top3, input_data.brand_name, input_data.business_type) + full["same_brand_locations"] = await _collect_same_brand_locations( + winner, top3, input_data.brand_name, input_data.business_type + ) except Exception as e: print(f"[ANALYZE/LLM] same_brand_locations 수집 실패 (무시): {e}") full["same_brand_locations"] = [] @@ -1059,7 +1141,10 @@ async def analyze_llm(input_data: SimulationInput): @app.post("/analyze/llm/async") -async def analyze_llm_async(input_data: SimulationInput) -> dict[str, Any]: +async def analyze_llm_async( + input_data: SimulationInput, + current_user: UserContext | None = Depends(get_optional_user), +) -> dict[str, Any]: """AI 분석 비동기 시작 — 즉시 job_id 반환. LangGraph 노드별 진행률 추적.""" from src.config.constants import MAPO_DISTRICTS from src.schemas.simulation_output import AnalysisOutput @@ -1070,6 +1155,9 @@ async def analyze_llm_async(input_data: SimulationInput) -> dict[str, Any]: set_progress, ) + # corp 다업종 brand auto-resolve + 운영 외 업종 차단 + _validate_and_resolve_brand(input_data, current_user) + if input_data.target_district not in MAPO_DISTRICTS: return { "status": "error", @@ -1128,7 +1216,9 @@ async def _run() -> None: logger.warning(f"[/analyze/llm/async] all_competitor_locations 실패 (무시): {ce}") full["all_competitor_locations"] = [] try: - full["same_brand_locations"] = await _collect_same_brand_locations(winner, top3, input_data.brand_name, input_data.business_type) + full["same_brand_locations"] = await _collect_same_brand_locations( + winner, top3, input_data.brand_name, input_data.business_type + ) except Exception as ce: logger.warning(f"[/analyze/llm/async] same_brand_locations 실패 (무시): {ce}") full["same_brand_locations"] = [] @@ -1137,6 +1227,13 @@ async def _run() -> None: payload = {k: v for k, v in full.items() if k in analysis_keys} payload["request_id"] = request_id payload["target_district"] = full.get("target_district") or input_data.target_district + # DEBUG: payload 직전 same_brand_locations 검증 (frontend 측 누락 의심 시) + logger.info( + f"[/analyze/llm/async] payload check job={job_id[:8]} " + f"same_brand={len(payload.get('same_brand_locations', []) or [])} " + f"all_competitor={len(payload.get('all_competitor_locations', []) or [])} " + f"keys_count={len(payload)}" + ) set_done(job_id, _safe_json(payload)) logger.info(f"[/analyze/llm/async] 완료 job={job_id[:8]}") except Exception as e: @@ -1172,7 +1269,10 @@ async def analyze_llm_job_status(job_id: str) -> dict[str, Any]: @app.post("/analyze/quick") -async def analyze_quick(input_data: SimulationInput): +async def analyze_quick( + input_data: SimulationInput, + current_user: UserContext | None = Depends(get_optional_user), +): """ LLM 없는 경량 랭킹 엔드포인트 (district_ranking 에이전트만 실행). @@ -1184,6 +1284,9 @@ async def analyze_quick(input_data: SimulationInput): from src.agents.nodes.district_ranking import district_ranking_node from src.agents.nodes.market_analyst import db_client + # corp 다업종 brand auto-resolve + 운영 외 업종 차단 + _validate_and_resolve_brand(input_data, current_user) + normalized_biz = _BIZ_TYPE_NORMALIZE.get(input_data.business_type.lower(), input_data.business_type) print(f"--- [API] /analyze/quick 요청: {input_data.target_district} / {normalized_biz} ---") @@ -1225,6 +1328,45 @@ class BizLookupRequest(BaseModel): company_name: str = "" +@app.get("/corp/operated-industries") +async def get_operated_industries( + biz_number: str | None = None, + current_user: UserContext | None = Depends(get_optional_user), +) -> dict: + """사용자 corp 의 운영 업종/브랜드 list 반환. + + Frontend 시뮬 입력 폼이 mount 시 호출 — dropdown 에서 운영 외 업종 disable 용. + + biz_number 우선순위: + 1. query param ``biz_number`` (frontend 명시) + 2. JWT 토큰의 user.user_id → users.biz_number 자동 추출 + + Returns: + 성공: ``{"company_name": str, "industries": [str, ...], "brands": [{name, industry, stores}, ...]}`` + 실패 (USER_NOT_FOUND/CORP_NOT_IN_FTC): ``{"industries": null, "error": ..., "company_name": ...}`` + 비회원 (biz_number 미입력 + 토큰 없음): ``{"industries": null}`` — 모든 업종 허용 + """ + from src.services.corp_brand_resolver import get_corp_industries + + biz = biz_number or _resolve_user_biz_number(current_user) + if not biz: + return {"industries": None, "company_name": None, "brands": []} + + portfolio = get_corp_industries(biz) + if "error" in portfolio: + return { + "industries": None, + "error": portfolio["error"], + "company_name": portfolio.get("company_name"), + "message": portfolio.get("message"), + } + return { + "company_name": portfolio["company_name"], + "industries": portfolio["industries"], + "brands": portfolio["brands"], + } + + @app.post("/biz/lookup") async def biz_lookup(req: BizLookupRequest): """사업자등록번호 + 기업명으로 프랜차이즈 브랜드 매핑. @@ -1659,7 +1801,10 @@ def _mock_simulation_response(target_district: str, request_id: str) -> dict: @app.post("/predict") -async def predict_districts(input_data: SimulationInput): +async def predict_districts( + input_data: SimulationInput, + current_user: UserContext | None = Depends(get_optional_user), +): """ 선택 동 1~4개 ML 예측 전용 엔드포인트 (LangGraph 미사용) @@ -1669,6 +1814,9 @@ async def predict_districts(input_data: SimulationInput): """ from src.config.constants import MAPO_DISTRICTS + # corp 다업종 brand auto-resolve + 운영 외 업종 차단 + _validate_and_resolve_brand(input_data, current_user) + target_districts = getattr(input_data, "target_districts", None) or [input_data.target_district] target_districts = [d for d in target_districts if d in MAPO_DISTRICTS][:4] @@ -1728,7 +1876,10 @@ async def predict_districts(input_data: SimulationInput): # 단계: 동별 _predict_single_district 가 끝날 때마다 progress = done/total. # --------------------------------------------------------------------------- @app.post("/predict/async") -async def predict_districts_async(input_data: SimulationInput) -> dict[str, Any]: +async def predict_districts_async( + input_data: SimulationInput, + current_user: UserContext | None = Depends(get_optional_user), +) -> dict[str, Any]: """ML 예측 비동기 시작 — 즉시 job_id 반환. 진행률은 status endpoint 폴링.""" from src.config.constants import MAPO_DISTRICTS from src.services.job_progress_store import ( @@ -1738,6 +1889,9 @@ async def predict_districts_async(input_data: SimulationInput) -> dict[str, Any] set_progress, ) + # corp 다업종 brand auto-resolve + 운영 외 업종 차단 + _validate_and_resolve_brand(input_data, current_user) + target_districts = getattr(input_data, "target_districts", None) or [input_data.target_district] target_districts = [d for d in target_districts if d in MAPO_DISTRICTS][:4] if not target_districts: @@ -1843,13 +1997,20 @@ async def predict_job_status(job_id: str) -> dict[str, Any]: @app.post("/simulate", deprecated=True) -async def run_simulation(input_data: SimulationInput, response: Response): +async def run_simulation( + input_data: SimulationInput, + response: Response, + current_user: UserContext | None = Depends(get_optional_user), +): """기본 시뮬레이션 엔드포인트""" response.headers["Deprecation"] = "true" response.headers["Link"] = '; rel="successor-version", ; rel="successor-version"' from src.config.constants import MAPO_DISTRICTS + # corp 다업종 brand auto-resolve + 운영 외 업종 차단 + _validate_and_resolve_brand(input_data, current_user) + if input_data.target_district not in MAPO_DISTRICTS: return { "status": "error", @@ -1872,7 +2033,9 @@ async def run_simulation(input_data: SimulationInput, response: Response): winner = result.get("winner_district") or input_data.target_district top3 = result.get("top_3_candidates") or [] try: - result["same_brand_locations"] = await _collect_same_brand_locations(winner, top3, input_data.brand_name, input_data.business_type) + result["same_brand_locations"] = await _collect_same_brand_locations( + winner, top3, input_data.brand_name, input_data.business_type + ) except Exception as ce: logger.warning(f"[/simulate] same_brand_locations 실패 (무시): {ce}") result["same_brand_locations"] = [] diff --git a/backend/src/schemas/simulation_input.py b/backend/src/schemas/simulation_input.py index 897ff42f..3fe26c0c 100644 --- a/backend/src/schemas/simulation_input.py +++ b/backend/src/schemas/simulation_input.py @@ -125,6 +125,13 @@ class SimulationInput(BaseModel): target_day_type: str | None = Field(default=None, description="타겟 요일: 'weekday' | 'weekend' | None(전체)") target_monthly_sales: int | None = Field(default=None, description="예상 월매출 (원). None=비율만 계산, 금액 제외") + # [corp_brand_resolver] biz_number 검증 트리거. + # frontend 가 보내거나 main.py 에서 JWT 토큰의 user.user_id → users.biz_number 자동 추출. + # corp 검증: 해당 biz_number 가 운영하는 brand+업종 list 매핑. + biz_number: str | None = Field( + default=None, description="사업자등록번호 (corp 다업종 검증 트리거 — 미입력 시 검증 skip)" + ) + @field_validator("business_type") @classmethod def _warn_unknown_business_type(cls, v: str) -> str: diff --git a/backend/src/services/corp_brand_resolver.py b/backend/src/services/corp_brand_resolver.py new file mode 100644 index 00000000..07373f9a --- /dev/null +++ b/backend/src/services/corp_brand_resolver.py @@ -0,0 +1,150 @@ +"""사업자번호 + 업종 → 같은 corp 의 해당 업종 자동 brand 매핑. + +다업종 법인 (예: (주)더본코리아 = 빽다방·홍콩반점·빽보이피자·새마을식당...) 의 경우 +회원가입 시 ``biz_brand_mapping`` 에 top frcsCnt brand 1개만 저장됨. +시뮬레이션 시 사용자가 다른 업종 (예: 중식) 선택하면 같은 corp 의 해당 업종 +가장 큰 brand (홍콩반점0410) 로 자동 resolve. + +운영 외 업종 선택 시 ``INDUSTRY_NOT_OPERATED`` 에러 + 운영 가능 업종 list 반환. + +설계: +- ``users.company_name`` (회원가입 시 기록) 기준 ``ftc_brand_franchise.corpNm`` 매칭 +- corpNm 표기 변형 흡수 — ILIKE + corp 핵심어 추출 (괄호/특수문자 제거) +- 매칭 brand 중 ``frcsCnt`` 큰 것 1개 선택 +- 운영 외 업종 → 거부 (사용자에게 운영 업종 list 안내) + +사용처: ``main.py`` 시뮬 endpoint 호출 직후, 시뮬 input.brand_name override. +""" + +from __future__ import annotations + +import logging +import re + +import sqlalchemy as sa + +from src.config.settings import settings + +logger = logging.getLogger(__name__) + + +_engine: sa.Engine | None = None + + +def _get_engine() -> sa.Engine: + global _engine + if _engine is None: + _engine = sa.create_engine(settings.postgres_url) + return _engine + + +# corpNm 핵심어 추출용 — '(주)', '㈜', '주식회사' 등 법인 prefix/suffix 제거 +_CORP_NOISE_RE = re.compile(r"\(주\)|㈜|주식회사|\([^)]*\)|\s+") + + +def _normalize_corp(name: str) -> str: + """corpNm 정규화 — 법인 표기 noise 제거 후 핵심어 추출.""" + if not name: + return "" + return _CORP_NOISE_RE.sub("", name).strip() + + +def get_corp_industries(biz_number: str) -> dict: + """사업자번호 → corp 의 운영 brand+업종 list. + + Args: + biz_number: 사업자등록번호 (하이픈 제거). + + Returns: + ``{"company_name": ..., "brands": [...], "industries": [...]}`` 또는 + ``{"error": "USER_NOT_FOUND" | "CORP_NOT_IN_FTC", ...}``. + """ + engine = _get_engine() + with engine.connect() as c: + user = c.execute( + sa.text("SELECT company_name FROM users WHERE biz_number = :biz"), + {"biz": biz_number}, + ).first() + if not user: + return {"error": "USER_NOT_FOUND", "biz_number": biz_number} + + company_name = user._mapping["company_name"] + norm = _normalize_corp(company_name) + if not norm: + return {"error": "INVALID_COMPANY_NAME", "company_name": company_name} + + # ftc_brand_franchise 에서 corpNm 매칭 (정규화 ILIKE) + # frcsCnt 큰 row 부터 정렬 — 같은 brand 의 다년 데이터는 max 사용 + rows = c.execute( + sa.text( + """ + SELECT "brandNm", "indutyMlsfcNm", MAX("frcsCnt") AS stores + FROM ftc_brand_franchise + WHERE "corpNm" IS NOT NULL + AND REGEXP_REPLACE("corpNm", '\\(주\\)|㈜|주식회사|\\([^)]*\\)|\\s+', '', 'g') ILIKE :norm + GROUP BY "brandNm", "indutyMlsfcNm" + ORDER BY stores DESC NULLS LAST + """ + ), + {"norm": f"%{norm}%"}, + ).fetchall() + + if not rows: + return { + "error": "CORP_NOT_IN_FTC", + "company_name": company_name, + "message": f"{company_name} 은(는) FTC 가맹사업 정보공개서에 등록되지 않은 corp 입니다.", + } + + brands = [ + {"name": r._mapping["brandNm"], "industry": r._mapping["indutyMlsfcNm"], "stores": r._mapping["stores"] or 0} + for r in rows + ] + industries = sorted({b["industry"] for b in brands if b["industry"]}) + + return { + "company_name": company_name, + "brands": brands, + "industries": industries, + } + + +def resolve_brand_for_industry(biz_number: str, industry: str) -> dict: + """사업자번호 + 업종 → 같은 corp 의 해당 업종 가장 큰 brand 자동 선택. + + Args: + biz_number: 사업자등록번호. + industry: 업종명 (FTC indutyMlsfcNm 표기 — 한식/중식/일식/...). + + Returns: + 성공: ``{"brand_name": ..., "industry": ..., "stores": int, + "alternatives": [...], "company_name": ...}``. + 실패: ``{"error": "INDUSTRY_NOT_OPERATED" | "USER_NOT_FOUND" | "CORP_NOT_IN_FTC", + "operated_industries": [...], ...}``. + """ + portfolio = get_corp_industries(biz_number) + if "error" in portfolio: + return portfolio + + matched = [b for b in portfolio["brands"] if b["industry"] == industry] + if not matched: + return { + "error": "INDUSTRY_NOT_OPERATED", + "company_name": portfolio["company_name"], + "requested_industry": industry, + "operated_industries": portfolio["industries"], + "message": ( + f"'{industry}' 업종은 {portfolio['company_name']} 운영 brand 에 없습니다. " + f"운영 가능 업종: {', '.join(portfolio['industries'])}" + ), + } + + # frcsCnt 내림차순 정렬됨 (get_corp_industries 가 보장) — 첫 항목 = top brand + top = matched[0] + return { + "brand_name": top["name"], + "industry": top["industry"], + "stores": top["stores"], + "alternatives": [b["name"] for b in matched[1:]], + "company_name": portfolio["company_name"], + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49b90f78..bd0f06c6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -82,7 +82,7 @@ import { ToastHost } from './components/simulation/ToastHost'; import { useCompletionToast } from './hooks/useCompletionToast'; import { useAbmCompletionToast } from './hooks/useAbmCompletionToast'; import { useSimulationStore } from './stores/simulationStore'; -import { getLivePopulation } from './api/client'; +import { getLivePopulation, getOperatedIndustries } from './api/client'; import { useCombinedSimResult, buildCombinedResult } from './hooks/useCombinedSimResult'; import NetworkBackground from './components/NetworkBackground'; import WaveBackground from './pages/landing/WaveBackground'; @@ -781,6 +781,36 @@ function SimulatorDashboard({ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [brand?.industry_medium]); + + // 회사 운영 업종 list — mount 시 backend `/corp/operated-industries` 호출. + // null = 비회원/미등록 corp (모든 업종 허용), [] 또는 list = 그 list 만 enable. + // FTC indutyMlsfcNm 표기 (한식/서양식/제과제빵/주점/피자/...) → frontend 라벨 매핑은 + // 위 FTC_TO_FRONTEND_INDUSTRY 재사용. 동일 frontend 라벨로 매핑되는 FTC 업종이 + // 하나라도 운영 중이면 그 frontend 라벨 enable. + const [operatedFrontendLabels, setOperatedFrontendLabels] = useState | null>(null); + const [operatedCompanyName, setOperatedCompanyName] = useState(null); + useEffect(() => { + let cancelled = false; + getOperatedIndustries().then((res) => { + if (cancelled) return; + setOperatedCompanyName(res.company_name); + if (!res.industries) { + setOperatedFrontendLabels(null); // 모든 업종 허용 + return; + } + const labels = new Set(); + for (const ftc of res.industries) { + const mapped = FTC_TO_FRONTEND_INDUSTRY[ftc]; + if (mapped) labels.add(mapped); + } + setOperatedFrontendLabels(labels); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.id]); + const [businessTypeOpen, setBusinessTypeOpen] = useState(false); const [storeArea, setStoreArea] = useState(initParams?.store_area ?? 15); // 평 const [targetPrice, setTargetPrice] = useState(initParams?.target_price_range ?? '5to10k'); @@ -1186,22 +1216,41 @@ function SimulatorDashboard({ {businessTypeOpen && (
- {BUSINESS_TYPES.map((type) => ( - - ))} + {BUSINESS_TYPES.map((type) => { + const disabled = + operatedFrontendLabels !== null && !operatedFrontendLabels.has(type); + return ( + + ); + })}
)} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 19bb3062..396c8908 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -261,6 +261,30 @@ export async function getLivePopulation(dongs?: string[]): Promise { return response.data; } +/** + * 회사 운영 업종/브랜드 list — 시뮬 폼 mount 시 호출. + * 토큰의 user → users.biz_number → ftc_brand_franchise.corpNm 매칭 결과. + * + * - industries=null: 비회원 또는 corp 미등록 — 모든 업종 허용 + * - industries=[...] : 운영 업종만 enable, 그 외는 dropdown 에서 disable + */ +export interface OperatedIndustriesResponse { + company_name: string | null; + industries: string[] | null; + brands?: { name: string; industry: string; stores: number }[]; + error?: string; + message?: string; +} + +export async function getOperatedIndustries(): Promise { + try { + const response = await apiClient.get('/corp/operated-industries'); + return response.data as OperatedIndustriesResponse; + } catch { + return { company_name: null, industries: null, brands: [] }; + } +} + // ───────────────────────────────────────────────────────── // simulation_history (JWT Bearer 필수 — interceptor가 자동 주입) // ───────────────────────────────────────────────────────── From ba01036585af1201d4658fad252d912f649fd188 Mon Sep 17 00:00:00 2001 From: yejin Date: Wed, 6 May 2026 13:26:59 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat(map):=20winner=20!=3D=20spot=201?= =?UTF-8?q?=EC=9C=84=20=EB=8F=99=20=EC=95=88=EB=82=B4=20=EB=9D=BC=EB=B2=A8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: winner=망원2동(매물 0건), spot 1위=망원1동(인접 동 매물) 케이스에서 사용자가 두 정보의 misalign 을 인지 못 함. 수정: TARGET 박스 아래에 amber warning 라벨 표시. - 조건: bestVacancy.dongName !== winner_district 일 때만 - 메시지: "추천 1순위 ○○동에 임대 매물이 없어, 인접 후보 동 △△동의 매물에서 공실 spot 을 자동 추천합니다" 분석 기준 동 변경 (옵션 2) 은 별도 작업으로 진행. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SimulationResult/sections/MapSection.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/src/components/SimulationResult/sections/MapSection.tsx b/frontend/src/components/SimulationResult/sections/MapSection.tsx index b1d0d492..eca8f3a0 100644 --- a/frontend/src/components/SimulationResult/sections/MapSection.tsx +++ b/frontend/src/components/SimulationResult/sections/MapSection.tsx @@ -257,6 +257,21 @@ export function MapSection({ simResult }: Props) { + {/* winner 동에 매물 0건이라 spot 1위가 다른 동에서 추천된 경우 안내 라벨. + bestVacancy.dongName !== winner 일 때만 표시. */} + {bestVacancy && bestVacancy.dongName !== district && district !== '—' && ( +
+ + 안내 + + + 추천 1순위 {district}에 임대 매물이 + 없어, 인접 후보 동{' '} + {bestVacancy.dongName}의 매물에서 + 공실 spot 을 자동 추천합니다. + +
+ )}
Date: Wed, 6 May 2026 13:29:49 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat(graph):=20PHASE=202=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20target=20=EC=9D=84=20spot=201=EC=9C=84=20=EB=8F=99?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=E2=80=94=20winner=20?= =?UTF-8?q?=EB=A7=A4=EB=AC=BC=200=EA=B1=B4=20=EC=BC=80=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: winner=망원2동(매물 0건) 케이스에서 PHASE 2 LLM 에이전트들 (market/ population/legal/demographic/trend/competitor) 이 망원2동 기준으로 분석 → 화면 표시 분석 결과가 spot 1위 동(망원1동) 와 다른 동 가리켜 misalign. 수정: PHASE 2 진입 시 target_district 결정 로직 변경. - 1순위: winner 동 spot 중 score(또는 listing_count) 1위 → 그 동 - 2순위: top3 동 spot 중 listing_count 1위 (winner 동 매물 0건일 때) - fallback: winner_district frontend buildBestVacancies 와 동일 로직 — 분석 기준과 화면 표시 일관. winner_district 변수는 그대로 보존 → 노란 강조·라벨·랭킹 정보 미영향. 캐시 키는 target_district 따라 자동 분리 (spot 동 캐시 신규 산출). Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/agents/graph.py | 60 ++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/backend/src/agents/graph.py b/backend/src/agents/graph.py index 77c41086..3cc34b51 100644 --- a/backend/src/agents/graph.py +++ b/backend/src/agents/graph.py @@ -75,22 +75,68 @@ async def llm_analysis_phase_node(state: AgentState) -> dict: """ Phase 2: 6개 LLM 에이전트 병렬 실행 - target_district를 Phase 1에서 확정된 winner_district로 덮어쓰고 실행. - 이로써 시장/인구/법률 분석 데이터가 추천 1위 동을 기준으로 생성됨. + target_district 결정 우선순위 (2026-05-06 변경): + 1. spot 1위 동 (실제 입주 가능 매물 위치) — winner 동 spot 우선, + 부족 시 top3 동 spot 으로 채움 (frontend buildBestVacancies 와 동일 로직). + 2. winner_district fallback (vacancy_spots 데이터 자체 없을 때). + + 의도: winner=망원2동(매물 0건) + spot 1위=망원1동(인접 동 매물) 케이스에서 + 분석 결과 (시장/인구/법률 등) 가 winner 가 아닌 실제 매물 동 기준으로 산출되어 + 화면 정보 일치 (사용자 요구). + + winner_district 자체는 그대로 보존 (노란 강조·라벨 표시용). """ t_start = time.perf_counter() - # winner_district를 분석 기준동으로 사용 (Phase 1에서 확정) winner = state.get("winner_district") or state.get("target_district", "") original_target = state.get("target_district", "") - if winner and winner != original_target: + top3 = state.get("top_3_candidates") or [] + vacancy_spots = state.get("vacancy_spots") or [] + + def _resolve_spot_dong() -> str: + """spot 1위 동 결정 — frontend buildBestVacancies 와 동일 로직.""" + + def _is_valid(s: dict) -> bool: + lat, lon = s.get("lat"), s.get("lon") + return isinstance(lat, (int, float)) and isinstance(lon, (int, float)) + + # 1순위: winner 동 spot 중 score(또는 listing_count) 1위 + winner_spots = [s for s in vacancy_spots if s.get("dong_name") == winner and _is_valid(s)] + if winner_spots: + winner_spots.sort( + key=lambda s: ( + -(s.get("score") if isinstance(s.get("score"), (int, float)) else float("-inf")), + -(s.get("listing_count") or 0), + ) + ) + return str(winner_spots[0].get("dong_name") or winner) + # 2순위: top3 동 spot 중 listing_count 1위 (winner 동 매물 0건 케이스) + top3_set = set(top3) + top3_spots = [ + s for s in vacancy_spots if s.get("dong_name") in top3_set and s.get("dong_name") != winner and _is_valid(s) + ] + if top3_spots: + top3_spots.sort(key=lambda s: -(s.get("listing_count") or 0)) + return str(top3_spots[0].get("dong_name") or winner) + return winner # fallback + + spot_dong = _resolve_spot_dong() + analysis_target = spot_dong or winner + + if winner and analysis_target != winner: + print( + f"--- [PHASE 2] target_district 교체: {original_target} → {analysis_target} " + f"(winner={winner}, spot 1위 동 기준 분석 — winner 동 매물 부족) ---" + ) + elif winner and winner != original_target: print(f"--- [PHASE 2] target_district 교체: {original_target} → {winner} (winner 기준 분석) ---") else: - print(f"--- [PHASE 2] target_district={winner} (변경 없음) ---") + print(f"--- [PHASE 2] target_district={analysis_target} (변경 없음) ---") - # winner를 target_district로 주입한 상태로 LLM 에이전트 실행 + # spot 1위 동을 target_district로 주입한 상태로 LLM 에이전트 실행. + # winner_district 는 그대로 보존 (별도 표시용). analysis_state = dict(state) - analysis_state["target_district"] = winner + analysis_state["target_district"] = analysis_target print("--- [PHASE 2] 6개 LLM 에이전트 병렬 실행 시작 ---") ( From 20f6fe385931c2b53b13d8127464254c0f6ad875 Mon Sep 17 00:00:00 2001 From: yejin Date: Wed, 6 May 2026 13:41:40 +0900 Subject: [PATCH 05/14] =?UTF-8?q?fix(market):=20=EC=A3=BC=EC=9A=94=20?= =?UTF-8?q?=EA=B2=BD=EC=9F=81=EC=A0=90=C2=B7=EC=A7=80=EB=8F=84=20=EB=A7=88?= =?UTF-8?q?=EC=BB=A4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?+=20spot=201=EC=9C=84=20=EC=A2=8C=ED=91=9C=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=20=EA=B1=B0=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: - 주요 경쟁점 카드: '컴포즈커피 망원시장점 50m' (분석 동 centroid 기준) - 지도 마커: 그 매장이 안 보임 (all_competitor_locations 누락) - 주요 경쟁점 카드의 거리가 spot 1위 좌표 기준 아님 (예: 무흐 82m 누락) 원인: 두 데이터 소스가 분리됨. - competitor_intel.samples: 분석 동 centroid 500m, top 20 cap - all_competitor_locations: 4동 centroid 1.5km, 357개 (samples 외 매장) 수정: - MapSection.buildCompetitors: all_competitor_locations + samples union → place_name + 좌표 dedup → 200 cap. 지도 마커에 두 소스 매장 모두 포함 (컴포즈 망원시장점 누락 해소). - MarketTab.topCompetitors: spot 1위 좌표(vacancy_spots top1) 결정 후 모든 매장의 haversine 재계산 → 가까운 5개. spot 1위 좌표 기준이라 무흐 82m 같은 진짜 가까운 매장이 카드 상단에 표시. → 카드와 지도 모두 spot 1위 좌표 기준 일관된 정보. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dashboard/tabs/MarketTab.tsx | 89 +++++++++++++++++-- .../SimulationResult/sections/MapSection.tsx | 49 ++++++++-- 2 files changed, 125 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx b/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx index 47dcf313..941f0474 100644 --- a/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx +++ b/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx @@ -19,6 +19,7 @@ import type { SimulationOutput } from '../../../../types'; import type { DetailModalContent } from '../shared/DetailModal'; import { getGuFromDong } from '../../../../data/seoulRegions'; import { MapSection } from '../../sections/MapSection'; +import { haversineM } from '../../sections/MarketMap'; import { IndicatorGrid } from '../../sections/IndicatorGrid'; import { DistrictRankings } from '../../sections/DistrictRankings'; import { AgentCard } from '../../shared/AgentCard'; @@ -80,11 +81,89 @@ export function MarketTab({ simResult }: Props) { const competitionIntensity = simResult.market_report?.competition_intensity ?? null; const rentIndex = simResult.market_report?.rent_index ?? null; - // 가까운 순 정렬, 상위 5 - const topCompetitors = [...samples] - .filter((s) => s.place_name) - .sort((a, b) => (a.distance_m ?? Infinity) - (b.distance_m ?? Infinity)) - .slice(0, 5); + // 주요 경쟁점 — spot 1위 좌표 기준 가까운 순 5개. + // 데이터 소스 통합: competitor_intel.samples (분석 동 centroid 500m, top 20) + + // all_competitor_locations (4동 centroid 1.5km, 357개) + // 두 소스 모두 사용해 매장 풀 최대화 + place_name 기준 dedup. + // spot 1위 좌표 = vacancy_spots 의 score 1위 (winner 우선, 부족 시 top3 동). + const _winnerForTop = simResult.winner_district || simResult.target_district; + const _top3 = (simResult.top_3_candidates ?? []).filter( + (d): d is string => typeof d === 'string', + ); + const _vacancySpots = ((simResult as SimulationOutput & { vacancy_spots?: unknown[] }).vacancy_spots ?? []) as Array<{ + lat?: unknown; + lon?: unknown; + dong_name?: unknown; + listing_count?: unknown; + score?: unknown; + }>; + const _spot1: { lat: number; lng: number } | null = (() => { + if (!_winnerForTop) return null; + const valid = (s: { lat?: unknown; lon?: unknown }) => + typeof s.lat === 'number' && + typeof s.lon === 'number' && + Number.isFinite(s.lat) && + Number.isFinite(s.lon); + const winnerSpots = _vacancySpots + .filter((s) => s.dong_name === _winnerForTop && valid(s)) + .sort((a, b) => { + const sa = typeof a.score === 'number' ? a.score : Number.NEGATIVE_INFINITY; + const sb = typeof b.score === 'number' ? b.score : Number.NEGATIVE_INFINITY; + if (sa !== sb) return sb - sa; + const la = typeof a.listing_count === 'number' ? a.listing_count : 0; + const lb = typeof b.listing_count === 'number' ? b.listing_count : 0; + return lb - la; + }); + if (winnerSpots[0]) return { lat: winnerSpots[0].lat as number, lng: winnerSpots[0].lon as number }; + const top3Set = new Set(_top3); + const top3Spots = _vacancySpots + .filter((s) => top3Set.has(String(s.dong_name)) && s.dong_name !== _winnerForTop && valid(s)) + .sort((a, b) => { + const la = typeof a.listing_count === 'number' ? a.listing_count : 0; + const lb = typeof b.listing_count === 'number' ? b.listing_count : 0; + return lb - la; + }); + if (top3Spots[0]) return { lat: top3Spots[0].lat as number, lng: top3Spots[0].lon as number }; + return null; + })(); + + const topCompetitors = (() => { + if (!_spot1) { + return [...samples] + .filter((s) => s.place_name) + .sort((a, b) => (a.distance_m ?? Infinity) - (b.distance_m ?? Infinity)) + .slice(0, 5); + } + type CandidateRaw = { + place_name?: string | null; + brand_name?: string | null; + lat?: number | null; + lng?: number | null; + lon?: number | null; + distance_m?: number | null; + }; + // samples (lat/lon or lat/lng 둘 다 가능) + all_competitor_locations 통합 + const allLocations = (simResult.all_competitor_locations ?? []) as CandidateRaw[]; + const merged: CandidateRaw[] = [...samples, ...allLocations]; + const seen = new Set(); + const sorted = merged + .map((m) => { + const lat = typeof m.lat === 'number' ? m.lat : null; + const lng = typeof m.lng === 'number' ? m.lng : typeof m.lon === 'number' ? m.lon : null; + if (lat == null || lng == null) return null; + const dist = haversineM(_spot1.lat, _spot1.lng, lat, lng); + return { ...m, lat, lng, distance_m: Math.round(dist) }; + }) + .filter((m): m is CandidateRaw & { lat: number; lng: number; distance_m: number } => m !== null && Boolean(m.place_name)) + .filter((m) => { + const key = `${m.place_name}_${m.lat.toFixed(5)}_${m.lng.toFixed(5)}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((a, b) => a.distance_m - b.distance_m); + return sorted.slice(0, 5); + })(); return (
diff --git a/frontend/src/components/SimulationResult/sections/MapSection.tsx b/frontend/src/components/SimulationResult/sections/MapSection.tsx index eca8f3a0..ddf14d00 100644 --- a/frontend/src/components/SimulationResult/sections/MapSection.tsx +++ b/frontend/src/components/SimulationResult/sections/MapSection.tsx @@ -32,11 +32,18 @@ const DONG_COORDS: Record = { }; function buildCompetitors(simResult: SimulationOutput): Competitor[] { - // all_competitor_locations (winner+top3 멀티동) 우선, fallback은 winner 단일 동 + // all_competitor_locations (winner+top3 4동, 1.5km 검색) + competitor_intel.samples (분석 동 500m) + // 두 소스 통합 → place_name + 좌표 기준 dedup → 최대 200개. + // 사용자 보고 "컴포즈커피 망원시장점이 카드엔 있는데 지도엔 없다" → samples 만 가진 매장 누락 차단. + const compIntel = simResult.competitor_intel as Record | null | undefined; + const competition = compIntel?.['competition_500m'] as + | { samples?: Array> } + | undefined; + const samplesRaw = competition?.samples ?? []; + if (simResult.all_competitor_locations?.length) { - return simResult.all_competitor_locations + const merged: Competitor[] = simResult.all_competitor_locations .filter((s) => typeof s.lat === 'number' && typeof s.lng === 'number') - .slice(0, 200) .map((s) => ({ place_name: s.place_name ?? '경쟁점', lat: s.lat, @@ -48,12 +55,38 @@ function buildCompetitors(simResult: SimulationOutput): Competitor[] { place_url: s.place_url ?? null, phone: s.phone ?? null, })); + // samples 추가 (all_competitor_locations 에 없는 매장 union) + for (const s of samplesRaw) { + const lat = typeof s.lat === 'number' ? s.lat : null; + const lng = + typeof s.lng === 'number' ? s.lng : typeof s.lon === 'number' ? (s.lon as number) : null; + if (lat == null || lng == null) continue; + merged.push({ + place_name: String(s.place_name ?? '경쟁점'), + lat, + lng, + distance_m: typeof s.distance_m === 'number' ? s.distance_m : undefined, + is_franchise: Boolean(s.is_franchise), + brand_name: typeof s.brand_name === 'string' ? s.brand_name : null, + daily_revenue: null, + place_url: typeof s.place_url === 'string' ? s.place_url : null, + phone: typeof s.phone === 'string' ? s.phone : null, + }); + } + // dedup — place_name + 좌표(소수 5자리) 동일하면 동일 매장으로 판단 + const seen = new Set(); + const deduped: Competitor[] = []; + for (const c of merged) { + const key = `${c.place_name}_${c.lat.toFixed(5)}_${c.lng.toFixed(5)}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(c); + if (deduped.length >= 200) break; + } + return deduped; } - const compIntel = simResult.competitor_intel as Record | null | undefined; - const competition = compIntel?.['competition_500m'] as - | { samples?: Array> } - | undefined; - const list = competition?.samples ?? []; + // all_competitor_locations 없으면 samples 만 사용 (구버전 응답 호환) + const list = samplesRaw; return list .filter( (s) => typeof s.lat === 'number' && (typeof s.lng === 'number' || typeof s.lon === 'number'), From 0d32c8ec0c8eb3f57678e42be237a4d16e1ce1b9 Mon Sep 17 00:00:00 2001 From: yejin Date: Wed, 6 May 2026 13:53:16 +0900 Subject: [PATCH 06/14] =?UTF-8?q?fix(market):=20=EB=8F=99=20=ED=95=9C?= =?UTF-8?q?=EB=88=88=EC=97=90=20=EA=B3=B5=EC=8B=A4=EB=A5=A0=20+=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EB=9D=BC=EB=B2=A8=20=E2=80=94=20analysisD?= =?UTF-8?q?ong(spot=201=EC=9C=84=20=EB=8F=99)=20=EA=B8=B0=EC=A4=80=20+=20?= =?UTF-8?q?=ED=81=B0=20=EB=B0=B0=EB=84=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: 1. '동 한눈에' 의 공실률이 winner 동(망원2동) 데이터로 표시 → district_rankings[winner].vacancy_rate 직접 참조 2. 마켓맵 위 작은 안내 라벨이 한눈에 안 들어옴 수정: 1. MarketTab 에 analysisDong 변수 추가 (spot 1위 동 우선, fallback winner) _spot1Info 에 dongName 포함 → analysisDong 추출 WinnerDistrictSummary props 에 analysisDong 전달, district_rankings 검색 시 사용 → 공실률 = analysisDong (망원1동) 데이터로 일관 2. 안내 라벨 → 큰 amber 배너로 강조 · AlertTriangle 아이콘 + "추천 입지 안내 — 매물 자동 보정" 헤더 · "○○동에 매물 없어 △△동의 매물에서 추천" 본문 · 추가 라인: "분석 결과/주요 경쟁점/동 한눈에 모두 △△동 기준으로 도출" · border-2 + box-shadow glow 로 시각 강조 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dashboard/tabs/MarketTab.tsx | 49 ++++++++++++++----- .../SimulationResult/sections/MapSection.tsx | 40 ++++++++++----- 2 files changed, 67 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx b/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx index 941f0474..0f27e088 100644 --- a/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx +++ b/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx @@ -97,7 +97,7 @@ export function MarketTab({ simResult }: Props) { listing_count?: unknown; score?: unknown; }>; - const _spot1: { lat: number; lng: number } | null = (() => { + const _spot1Info: { lat: number; lng: number; dongName: string } | null = (() => { if (!_winnerForTop) return null; const valid = (s: { lat?: unknown; lon?: unknown }) => typeof s.lat === 'number' && @@ -114,7 +114,12 @@ export function MarketTab({ simResult }: Props) { const lb = typeof b.listing_count === 'number' ? b.listing_count : 0; return lb - la; }); - if (winnerSpots[0]) return { lat: winnerSpots[0].lat as number, lng: winnerSpots[0].lon as number }; + if (winnerSpots[0]) + return { + lat: winnerSpots[0].lat as number, + lng: winnerSpots[0].lon as number, + dongName: String(winnerSpots[0].dong_name ?? _winnerForTop), + }; const top3Set = new Set(_top3); const top3Spots = _vacancySpots .filter((s) => top3Set.has(String(s.dong_name)) && s.dong_name !== _winnerForTop && valid(s)) @@ -123,9 +128,18 @@ export function MarketTab({ simResult }: Props) { const lb = typeof b.listing_count === 'number' ? b.listing_count : 0; return lb - la; }); - if (top3Spots[0]) return { lat: top3Spots[0].lat as number, lng: top3Spots[0].lon as number }; + if (top3Spots[0]) + return { + lat: top3Spots[0].lat as number, + lng: top3Spots[0].lon as number, + dongName: String(top3Spots[0].dong_name ?? _winnerForTop), + }; return null; })(); + const _spot1 = _spot1Info ? { lat: _spot1Info.lat, lng: _spot1Info.lng } : null; + // 분석 기준 동 — spot 1위 동(매물 있는 곳) 우선, 없으면 winner. + // 동 한눈에 / 공실률 등 frontend 가 winner 동 데이터 직접 참조하던 곳에서 사용. + const analysisDong = _spot1Info?.dongName ?? _winnerForTop ?? null; const topCompetitors = (() => { if (!_spot1) { @@ -208,6 +222,7 @@ export function MarketTab({ simResult }: Props) { rentIndex={rentIndex} topCompetitors={topCompetitors} simResult={simResult} + analysisDong={analysisDong} />
@@ -482,6 +497,7 @@ interface SidebarProps { rentIndex: number | null; topCompetitors: MarketCompetitorSample[]; simResult: SimulationOutput; + analysisDong: string | null; } function MarketAnalysisSidebar({ @@ -491,6 +507,7 @@ function MarketAnalysisSidebar({ rentIndex, topCompetitors, simResult, + analysisDong, }: SidebarProps) { const metrics: Array<{ label: string; value: string }> = [ { @@ -590,21 +607,31 @@ function MarketAnalysisSidebar({
{/* ─ 섹션 4: winner 동 한눈에 — 핵심 고객 + 공실률 + 4분기 매출 흐름 (사실 기반, mock 0) ─ */} - + ); } -/** A + B 묶음 — winner 동의 기본 통계 3 metric + 분기별 매출 sparkline. +/** A + B 묶음 — 분석 기준 동(spot 1위 동, fallback: winner)의 기본 통계 + 분기별 매출 sparkline. + * 2026-05-06 변경: winner 동 매물 0건 케이스에서도 화면 정보 일관성 — 모든 분석 데이터를 + * spot 1위 동 기준으로 추출 (PHASE 2 target 변경과 동일 방향). * 데이터 출처: - * - 핵심 고객층 = demographic_report.core_demographic (age + gender) - * - 공실률 = district_rankings[winner].vacancy_rate (이미 percent 단위, 추가 *100 금지 — 회귀 방지) - * - 분기 평균 매출 / 4분기 sparkline = quarterly_projection (winner 동 4 점) + * - 핵심 고객층 = demographic_report.core_demographic (PHASE 2 결과 — spot 1위 동 LLM 산출) + * - 공실률 = district_rankings[analysisDong].vacancy_rate (winner 직접 참조 → spot 1위 동으로 변경) + * - 분기 평균 매출 / 4분기 sparkline = quarterly_projection * 데이터 모두 비어있으면 섹션 자체 hide. */ -function WinnerDistrictSummary({ simResult }: { simResult: SimulationOutput }) { - const winner = simResult.winner_district; +function WinnerDistrictSummary({ + simResult, + analysisDong, +}: { + simResult: SimulationOutput; + analysisDong: string | null; +}) { + const targetDong = analysisDong ?? simResult.winner_district ?? null; const winnerRanking = - winner != null ? (simResult.district_rankings ?? []).find((r) => r.district === winner) : null; + targetDong != null + ? (simResult.district_rankings ?? []).find((r) => r.district === targetDong) + : null; const vacancyRate = winnerRanking?.vacancy_rate ?? null; const demo = simResult.demographic_report; diff --git a/frontend/src/components/SimulationResult/sections/MapSection.tsx b/frontend/src/components/SimulationResult/sections/MapSection.tsx index ddf14d00..727159a9 100644 --- a/frontend/src/components/SimulationResult/sections/MapSection.tsx +++ b/frontend/src/components/SimulationResult/sections/MapSection.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { AlertTriangle } from 'lucide-react'; import type { SimulationOutput } from '../../../types'; import { useAuth } from '../../../auth/AuthContext'; import { getDongCount, getGuFromDong } from '../../../data/seoulRegions'; @@ -290,19 +291,36 @@ export function MapSection({ simResult }: Props) {
- {/* winner 동에 매물 0건이라 spot 1위가 다른 동에서 추천된 경우 안내 라벨. + {/* winner != spot 1위 동 케이스 안내 — 큰 배너로 강조. bestVacancy.dongName !== winner 일 때만 표시. */} {bestVacancy && bestVacancy.dongName !== district && district !== '—' && ( -
- - 안내 - - - 추천 1순위 {district}에 임대 매물이 - 없어, 인접 후보 동{' '} - {bestVacancy.dongName}의 매물에서 - 공실 spot 을 자동 추천합니다. - +
+
+ +
+
+ 추천 입지 안내 — 매물 자동 보정 +
+
+ 종합 점수 1순위 추천 동{' '} + + {district} + + 에 실제 임대 매물이 없어, 인접 + 후보 동{' '} + + {bestVacancy.dongName} + + 의 매물 중 최적 위치를 공실 spot 1위로 자동 추천합니다. +
+
+ ▸ 분석 결과(반경 500m 동일업종, 평균 거리, 경쟁 강도, 임대료 인덱스), 주요 경쟁점, + 동 한눈에 — 모두{' '} + {bestVacancy.dongName} 기준으로 + 도출됩니다. +
+
+
)}
From 2c5a3103ef77c483e74297384c5edae0efa20e22 Mon Sep 17 00:00:00 2001 From: yejin Date: Wed, 6 May 2026 15:06:35 +0900 Subject: [PATCH 07/14] =?UTF-8?q?fix(market):=20=EC=8B=9C=EB=AE=AC?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EA=B2=BD=EC=9F=81=EC=97=85=EC=B2=B4=20?= =?UTF-8?q?=EB=B3=80=EB=8F=99=20+=20=ED=8C=9D=EC=97=85=20=EA=B0=80?= =?UTF-8?q?=EB=A0=A4=EC=A7=90=20+=20top5=20=EC=A7=80=EB=8F=84=20=EA=B0=95?= =?UTF-8?q?=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 ① — spot 1위 기준 반경 500m 경쟁업체 매 시뮬마다 바뀜 Root cause: _load_vacancy_spots SQL 에 ORDER BY 없음 → PG 자연순서 비결정 → vacancy_spots ordering 매번 다름 → competitor_intel _matched[0] 비결정 → spot_lat/lon 캐시 키 매번 다름 → 카니발 분석/sample 새로 생성 Fix A: ORDER BY NaverVacancy.id 추가 (district_ranking.py) Fix B: competitor_intel _matched 를 score 1위 (없으면 listing_count) 정렬 → frontend bestVacancy 좌표와 backend 분석 기준점 완전 통일 이슈 ② — 주요 경쟁점 5개 지도에 강조 표시 · MarketMap topCompetitors prop 추가 (rank, lat, lng) · 좌표 매칭 시 노란 큰 동그라미(28px) + 번호 라벨 + zIndex=6 · MapSection → MarketMap 흐름 연결, MarketTab 에서 topCompetitors 전달 · 범례에 "주요 경쟁점 (1~5위)" 항목 추가 이슈 ③ — 다른 경쟁점 마커 팝업창 가려져 안 보임 Root cause: Kakao InfoWindow 가 CustomOverlay (펄싱핀 zIndex=5/번호핀=5) 에 가려짐 Fix: InfoWindow → CustomOverlay (zIndex=100) 로 통일 · X 버튼 직접 부착 (CustomOverlay 엔 close UI 없음) · 마커 위쪽 화살표 + transform translateY(-10px) 로 깔끔한 배치 · 경쟁점/자사매장 두 핸들러 모두 openPopup 헬퍼로 통합 Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/agents/nodes/competitor_intel.py | 11 +- backend/src/agents/nodes/district_ranking.py | 30 +++-- .../dashboard/tabs/MarketTab.tsx | 10 +- .../SimulationResult/sections/MapSection.tsx | 51 +++++++- .../SimulationResult/sections/MarketMap.tsx | 116 +++++++++++++----- 5 files changed, 168 insertions(+), 50 deletions(-) diff --git a/backend/src/agents/nodes/competitor_intel.py b/backend/src/agents/nodes/competitor_intel.py index a03d4782..a0a31e50 100644 --- a/backend/src/agents/nodes/competitor_intel.py +++ b/backend/src/agents/nodes/competitor_intel.py @@ -323,7 +323,10 @@ def _make_competitor_attr( } # winner 매물 좌표 (legal agent 와 동일 기준점) — 일관성 확보. - # state.vacancy_spots 에서 winner_district 매칭 spot 추출, 없으면 첫 spot. + # graph.py 가 target_district 를 spot 1위 동으로 이미 교체한 상태로 진입. + # → 그 동의 spot 중 score 1위 (없으면 listing_count 1위) 좌표 사용. + # frontend buildBestVacancies 의 1위 spot 좌표와 동일 — 분석 기준점 완전 통일. + # 이전 _matched[0] 은 정렬 없는 자연순서 첫 spot 이라 비결정적이었음. spot_lat: float | None = None spot_lon: float | None = None _vac_spots = state.get("vacancy_spots") or [] @@ -331,6 +334,12 @@ def _make_competitor_attr( _matched = [ s for s in _vac_spots if isinstance(s, dict) and s.get("dong_name") == target_district ] + _matched.sort( + key=lambda s: ( + -(s.get("score") if isinstance(s.get("score"), (int, float)) else float("-inf")), + -(s.get("listing_count") or 0), + ) + ) _spot = _matched[0] if _matched else _vac_spots[0] try: _slat = _spot.get("lat") if isinstance(_spot, dict) else None diff --git a/backend/src/agents/nodes/district_ranking.py b/backend/src/agents/nodes/district_ranking.py index be9e3482..1a484235 100644 --- a/backend/src/agents/nodes/district_ranking.py +++ b/backend/src/agents/nodes/district_ranking.py @@ -304,17 +304,25 @@ async def _load_vacancy_spots(dong_names: list[str]) -> list[dict]: """ try: async with db_client.get_session() as session: - stmt = select( - NaverVacancy.id, - NaverVacancy.lat, - NaverVacancy.lon, - NaverVacancy.dong_name, - NaverVacancy.listing_count, - ).where( - NaverVacancy.trade_type == "월세", - NaverVacancy.dong_name.in_(dong_names), - NaverVacancy.lat.isnot(None), - NaverVacancy.lon.isnot(None), + # ORDER BY id 필수 — PostgreSQL 은 ORDER BY 없으면 row 순서 비결정 (vacuum/insert 후 자연순서 변동). + # 결정성 잃으면 _matched[0]/spots[:4] 가 매 시뮬마다 다른 spot 을 선택해서 + # competitor_intel 캐시 키(spot 좌표 포함) 가 매번 달라지고 새 카니발/sample 결과로 덮여씀. + # → 사용자 보고 "spot 1위 기준 반경 500m 경쟁업체가 매 시뮬마다 바뀜" 의 root cause. + stmt = ( + select( + NaverVacancy.id, + NaverVacancy.lat, + NaverVacancy.lon, + NaverVacancy.dong_name, + NaverVacancy.listing_count, + ) + .where( + NaverVacancy.trade_type == "월세", + NaverVacancy.dong_name.in_(dong_names), + NaverVacancy.lat.isnot(None), + NaverVacancy.lon.isnot(None), + ) + .order_by(NaverVacancy.id) ) rows = (await session.execute(stmt)).fetchall() spots = [ diff --git a/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx b/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx index 0f27e088..fcc9002d 100644 --- a/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx +++ b/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx @@ -90,7 +90,8 @@ export function MarketTab({ simResult }: Props) { const _top3 = (simResult.top_3_candidates ?? []).filter( (d): d is string => typeof d === 'string', ); - const _vacancySpots = ((simResult as SimulationOutput & { vacancy_spots?: unknown[] }).vacancy_spots ?? []) as Array<{ + const _vacancySpots = ((simResult as SimulationOutput & { vacancy_spots?: unknown[] }) + .vacancy_spots ?? []) as Array<{ lat?: unknown; lon?: unknown; dong_name?: unknown; @@ -168,7 +169,10 @@ export function MarketTab({ simResult }: Props) { const dist = haversineM(_spot1.lat, _spot1.lng, lat, lng); return { ...m, lat, lng, distance_m: Math.round(dist) }; }) - .filter((m): m is CandidateRaw & { lat: number; lng: number; distance_m: number } => m !== null && Boolean(m.place_name)) + .filter( + (m): m is CandidateRaw & { lat: number; lng: number; distance_m: number } => + m !== null && Boolean(m.place_name), + ) .filter((m) => { const key = `${m.place_name}_${m.lat.toFixed(5)}_${m.lng.toFixed(5)}`; if (seen.has(key)) return false; @@ -210,7 +214,7 @@ export function MarketTab({ simResult }: Props) { {/* 기존 MapSection 재활용 (Kakao SDK) */}
- +
diff --git a/frontend/src/components/SimulationResult/sections/MapSection.tsx b/frontend/src/components/SimulationResult/sections/MapSection.tsx index 727159a9..b5869249 100644 --- a/frontend/src/components/SimulationResult/sections/MapSection.tsx +++ b/frontend/src/components/SimulationResult/sections/MapSection.tsx @@ -9,6 +9,13 @@ import { MarketMap, haversineM, type Competitor, type RankingEntry } from './Mar interface Props { simResult: SimulationOutput; + // 주요 경쟁점 top5 — 사이드바 카드와 동일. MarketMap 에서 큰 번호 라벨로 강조. + // lat/lng 가 없는 항목은 내부에서 필터. + topCompetitors?: Array<{ + place_name?: string | null; + lat?: number | null; + lng?: number | null; + }>; } const DEFAULT_MAPO_CENTER = { lat: 37.5558, lng: 126.9193 }; @@ -221,7 +228,7 @@ function buildBestVacancies(simResult: SimulationOutput): BestVacancy[] { return deduped; } -export function MapSection({ simResult }: Props) { +export function MapSection({ simResult, topCompetitors }: Props) { // Memoize 대상: buildCompetitors/buildRankings/buildCenter가 매 렌더마다 새 배열 참조를 만들면 // MarketMap useEffect deps가 매번 바뀌어 지도·choropleth가 무한 재초기화된다. const competitors = useMemo(() => buildCompetitors(simResult), [simResult]); @@ -233,6 +240,24 @@ export function MapSection({ simResult }: Props) { const center = bestVacancy ? { lat: bestVacancy.lat, lng: bestVacancy.lng } : fallbackCenter; // 자사 매장 좌표 (winner+top3 4동 안) — 로고 마커 + 영업구역 반경 원 표시용. const sameBrandLocations = useMemo(() => simResult.same_brand_locations ?? [], [simResult]); + // topCompetitors → MarketMap 형식 (rank 부여) 변환. lat/lng 둘 다 number 필수. + const topCompetitorsForMap = useMemo( + () => + (topCompetitors ?? []) + .filter( + (t): t is { place_name?: string | null; lat: number; lng: number } => + typeof t.lat === 'number' && typeof t.lng === 'number', + ) + .slice(0, 5) + .map((t, idx) => ({ + place_name: t.place_name ?? '경쟁점', + lat: t.lat, + lng: t.lng, + rank: idx + 1, + })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [topCompetitors], + ); // 사용자 입력 영업구역 거리 — store.params 에서 직접 (응답에 echo 안 됨). const territoryRadiusM = useSimulationStore((s) => s.params?.territory_radius_m); @@ -337,6 +362,7 @@ export function MapSection({ simResult }: Props) { sameBrandLocations={sameBrandLocations} territoryRadiusM={territoryRadiusM ?? null} userBrand={brand} + topCompetitors={topCompetitorsForMap} /> {/* Layer 6 — 좌하단 범례 패널 */} @@ -353,6 +379,29 @@ export function MapSection({ simResult }: Props) { {totalCompetitors} + {topCompetitorsForMap.length > 0 && ( +
+ + 1 + + 주요 경쟁점 (1~5위) +
+ )}
; } // 브랜드명 정규화 — "메가엠지씨커피(MEGA MGC COFFEE)" vs "메가엠지씨커피" 같은 변형을 동일 취급. @@ -213,6 +215,24 @@ function escapeHtml(s: string): string { .replace(/'/g, '''); } +// 팝업 wrapper — innerHTML 로 채우고 X 버튼 클릭 시 onClose 호출. +// CustomOverlay 자체엔 close UI 가 없어 직접 추가해야 함. zIndex 는 CustomOverlay 옵션으로 강제. +function buildPopupOverlayContent(innerHtml: string, onClose: () => void): HTMLElement { + const wrap = document.createElement('div'); + wrap.style.cssText = 'position:relative;transform:translateY(-10px);pointer-events:auto;'; + wrap.innerHTML = ` +
+ ${innerHtml} + +
+
+ `; + const btn = wrap.querySelector('button[data-popup-close="1"]'); + if (btn) btn.addEventListener('click', onClose); + return wrap; +} + function buildCompetitorInfoHtml( c: Competitor, radius: number, @@ -266,11 +286,14 @@ export function MarketMap({ sameBrandLocations = [], territoryRadiusM = null, userBrand = null, + topCompetitors = [], }: MarketMapProps) { const { ready, error, kakao } = useKakaoMap(); const containerRef = useRef(null); const overlayLayersRef = useRef void }>>([]); - const infoWindowRef = useRef<{ open: (m: unknown) => void; close: () => void } | null>(null); + // 팝업 — 기존 Kakao InfoWindow 는 다른 CustomOverlay(펄싱 핀/번호 핀) 에 가려지는 이슈가 있어 + // CustomOverlay (zIndex 100) 로 통일. close 는 X 버튼 클릭 또는 다른 마커 클릭 시. + const popupRef = useRef<{ setMap: (m: unknown) => void } | null>(null); const [geoError, setGeoError] = useState(null); useEffect(() => { @@ -289,9 +312,9 @@ export function MarketMap({ overlayLayersRef.current.forEach((layer) => layer.setMap(null)); overlayLayersRef.current = []; - if (infoWindowRef.current) { - infoWindowRef.current.close(); - infoWindowRef.current = null; + if (popupRef.current) { + popupRef.current.setMap(null); + popupRef.current = null; } // 핀/반경원 좌표 우선순위: @@ -415,6 +438,34 @@ export function MarketMap({ const sameBrandPosKeys = new Set( sameBrandLocations.map((s) => `${s.lat.toFixed(5)},${s.lng.toFixed(5)}`), ); + // 주요 경쟁점 top5 매칭용 — 좌표(소수4자리) 기준 rank lookup. + const topCompetitorRankByPos = new Map(); + topCompetitors.forEach((tc) => { + if (typeof tc.lat !== 'number' || typeof tc.lng !== 'number') return; + topCompetitorRankByPos.set(`${tc.lat.toFixed(4)},${tc.lng.toFixed(4)}`, tc.rank); + }); + + // 팝업 열기/닫기 헬퍼 — InfoWindow 대체. zIndex=100 강제로 항상 최상단. + const openPopup = (pos: unknown, innerHtml: string) => { + if (popupRef.current) popupRef.current.setMap(null); + const closeFn = () => { + if (popupRef.current) { + popupRef.current.setMap(null); + popupRef.current = null; + } + }; + const content = buildPopupOverlayContent(innerHtml, closeFn); + const overlay = new maps.CustomOverlay({ + position: pos, + content, + xAnchor: 0.5, + yAnchor: 1.0, + zIndex: 100, + }); + overlay.setMap(mapInstance); + popupRef.current = overlay; + }; + competitors.forEach((c) => { if (typeof c.lat !== 'number' || typeof c.lng !== 'number') return; // 자사 브랜드 매칭 — competitors 안에 자사 매장이 들어와 있으면 별표 마커로 분기. @@ -427,6 +478,9 @@ export function MarketMap({ const distFromCenter = haversineM(withinCenterLat, withinCenterLng, c.lat, c.lng); const within = distFromCenter <= radius; const pos = new maps.LatLng(c.lat, c.lng); + // 주요 경쟁점 top5 매칭 — 사이드바 카드와 동일 좌표면 큰 번호 라벨로 강조. + const top5Rank = topCompetitorRankByPos.get(`${c.lat.toFixed(4)},${c.lng.toFixed(4)}`); + const isTop5 = top5Rank != null && top5Rank >= 1 && top5Rank <= 5; const dot = document.createElement('div'); if (isSelfBrand) { @@ -435,6 +489,12 @@ export function MarketMap({ 'position:relative;width:24px;height:24px;display:flex;align-items:center;justify-content:center;background:#fbbf24;border:2px solid #ffffff;border-radius:9999px;box-shadow:0 0 8px rgba(251,191,36,0.6);font-size:12px;font-weight:900;color:#1c1917;cursor:pointer;'; dot.innerHTML = '★'; dot.title = `${c.brand_name || '자사매장'} · ${c.place_name}`; + } else if (isTop5) { + // 주요 경쟁점 top5 — 노란색 큰 동그라미 + 번호 라벨 (사이드바 카드와 동일 매장). + dot.style.cssText = + 'position:relative;width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:#facc15;border:2.5px solid #ffffff;border-radius:9999px;box-shadow:0 0 10px rgba(250,204,21,0.7);font-size:13px;font-weight:900;color:#1c1917;cursor:pointer;'; + dot.innerHTML = String(top5Rank); + dot.title = `주요 경쟁점 ${top5Rank}위 — ${c.place_name}`; } else { dot.style.cssText = within ? 'width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:11px solid #ef4444;filter:drop-shadow(0 0 3px rgba(239,68,68,0.7));cursor:pointer;' @@ -444,14 +504,7 @@ export function MarketMap({ dot.addEventListener('click', (ev) => { ev.stopPropagation(); - if (infoWindowRef.current) infoWindowRef.current.close(); - const iw = new maps.InfoWindow({ - position: pos, - content: buildCompetitorInfoHtml(c, radius, withinCenterLat, withinCenterLng), - removable: true, - }); - iw.open(mapInstance); - infoWindowRef.current = iw; + openPopup(pos, buildCompetitorInfoHtml(c, radius, withinCenterLat, withinCenterLng)); }); const overlay = new maps.CustomOverlay({ @@ -459,7 +512,7 @@ export function MarketMap({ content: dot, xAnchor: 0.5, yAnchor: 0.5, - zIndex: isSelfBrand ? 4 : 2, + zIndex: isTop5 ? 6 : isSelfBrand ? 4 : 2, }); overlay.setMap(mapInstance); overlayLayersRef.current.push(overlay); @@ -477,7 +530,6 @@ export function MarketMap({ logo.title = `${s.brand_name || '자사매장'} · ${s.place_name}`; 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 @@ -489,23 +541,18 @@ export function MarketMap({ const phoneHtml = s.phone ? `` : ''; - const iw = new maps.InfoWindow({ - position: pos, - content: `
-
- - ${nameHtml} -
-
-
${placeNameHtml}
-
${escapeHtml(s.dong_name || '')} ${escapeHtml(s.address || '')}
- ${phoneHtml} -
-
`, - removable: true, - }); - iw.open(mapInstance); - infoWindowRef.current = iw; + const innerHtml = `
+
+ + ${nameHtml} +
+
+
${placeNameHtml}
+
${escapeHtml(s.dong_name || '')} ${escapeHtml(s.address || '')}
+ ${phoneHtml} +
+
`; + openPopup(pos, innerHtml); }); const sameBrandOverlay = new maps.CustomOverlay({ position: pos, @@ -558,9 +605,9 @@ export function MarketMap({ return () => { overlayLayersRef.current.forEach((layer) => layer.setMap(null)); overlayLayersRef.current = []; - if (infoWindowRef.current) { - infoWindowRef.current.close(); - infoWindowRef.current = null; + if (popupRef.current) { + popupRef.current.setMap(null); + popupRef.current = null; } }; }, [ @@ -578,6 +625,7 @@ export function MarketMap({ sameBrandLocations, territoryRadiusM, userBrand, + topCompetitors, ]); if (error) { From edb4e54d652e4e697890681417e77e56367c33b2 Mon Sep 17 00:00:00 2001 From: yejin Date: Wed, 6 May 2026 15:49:17 +0900 Subject: [PATCH 08/14] =?UTF-8?q?fix(market):=20top5=20=EB=A7=A4=EC=9E=A5?= =?UTF-8?q?=EC=9D=B4=20=EB=B9=A8=EA=B0=84=20=EC=82=BC=EA=B0=81=ED=98=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=95=88=20=EA=B7=B8=EB=A0=A4=EC=A7=80?= =?UTF-8?q?=EB=8D=98=20=EB=B2=84=EA=B7=B8=20=E2=80=94=20=EB=B2=88=ED=98=B8?= =?UTF-8?q?=20=EB=9D=BC=EB=B2=A8=20=EB=B3=84=EB=8F=84=20overlay=20?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회귀: 이전 commit 에서 top5 매장을 노란 동그라미로 '치환' 해서 반경 안 빨간 삼각형이 5개 사라진 것처럼 보이는 문제. 사용자 보고: "반경 내 경쟁점 어디 갔냐". 수정: · 빨간 삼각형은 모든 경쟁점에 항상 그대로 그림 (반경 내/외 색·크기 분기 보존) · top5 매장은 그 위에 별도 노란 번호 badge overlay 추가 (yAnchor=1.6 으로 위쪽 띄움) · 매장 누락 X — 시각적으로 빨간 삼각형 + 노란 번호 둘 다 보임 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SimulationResult/sections/MarketMap.tsx | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/SimulationResult/sections/MarketMap.tsx b/frontend/src/components/SimulationResult/sections/MarketMap.tsx index d08fd66e..160d2316 100644 --- a/frontend/src/components/SimulationResult/sections/MarketMap.tsx +++ b/frontend/src/components/SimulationResult/sections/MarketMap.tsx @@ -489,17 +489,13 @@ export function MarketMap({ 'position:relative;width:24px;height:24px;display:flex;align-items:center;justify-content:center;background:#fbbf24;border:2px solid #ffffff;border-radius:9999px;box-shadow:0 0 8px rgba(251,191,36,0.6);font-size:12px;font-weight:900;color:#1c1917;cursor:pointer;'; dot.innerHTML = '★'; dot.title = `${c.brand_name || '자사매장'} · ${c.place_name}`; - } else if (isTop5) { - // 주요 경쟁점 top5 — 노란색 큰 동그라미 + 번호 라벨 (사이드바 카드와 동일 매장). - dot.style.cssText = - 'position:relative;width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:#facc15;border:2.5px solid #ffffff;border-radius:9999px;box-shadow:0 0 10px rgba(250,204,21,0.7);font-size:13px;font-weight:900;color:#1c1917;cursor:pointer;'; - dot.innerHTML = String(top5Rank); - dot.title = `주요 경쟁점 ${top5Rank}위 — ${c.place_name}`; } else { + // 빨간 삼각형 — 반경 안 진한, 밖 흐린. top5 여부와 무관하게 항상 그려짐 + // (top5 는 이 위에 별도 번호 동그라미 overlay 로 추가 표시 — 매장 누락 X). dot.style.cssText = within ? 'width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:11px solid #ef4444;filter:drop-shadow(0 0 3px rgba(239,68,68,0.7));cursor:pointer;' : 'width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:9px solid #ef4444;opacity:0.45;cursor:pointer;'; - dot.title = c.place_name; + dot.title = isTop5 ? `주요 경쟁점 ${top5Rank}위 — ${c.place_name}` : c.place_name; } dot.addEventListener('click', (ev) => { @@ -512,10 +508,28 @@ export function MarketMap({ content: dot, xAnchor: 0.5, yAnchor: 0.5, - zIndex: isTop5 ? 6 : isSelfBrand ? 4 : 2, + zIndex: isSelfBrand ? 4 : 2, }); overlay.setMap(mapInstance); overlayLayersRef.current.push(overlay); + + // top5 별도 번호 라벨 — 빨간 삼각형 위에 약간 떠있게 표시 (매장은 빨간 삼각형으로 그대로). + if (isTop5 && !isSelfBrand) { + const badge = document.createElement('div'); + badge.style.cssText = + 'position:relative;width:20px;height:20px;display:flex;align-items:center;justify-content:center;background:#facc15;border:2px solid #ffffff;border-radius:9999px;box-shadow:0 0 8px rgba(250,204,21,0.8);font-size:11px;font-weight:900;color:#1c1917;cursor:pointer;pointer-events:none;'; + badge.innerHTML = String(top5Rank); + badge.title = `주요 경쟁점 ${top5Rank}위 — ${c.place_name}`; + const badgeOverlay = new maps.CustomOverlay({ + position: pos, + content: badge, + xAnchor: 0.5, + yAnchor: 1.6, // 빨간 삼각형 위쪽으로 띄움 — 삼각형이 그대로 보임 + zIndex: 6, + }); + badgeOverlay.setMap(mapInstance); + overlayLayersRef.current.push(badgeOverlay); + } }); // Layer 3 — 자사 매장 마커 (로고 아이콘 별표 only — 영업구역 점선 원은 사용자 요구로 제거) From ccf27757a42c6e3f762e00b11413fc8ebebe5947 Mon Sep 17 00:00:00 2001 From: yejin Date: Wed, 6 May 2026 15:58:20 +0900 Subject: [PATCH 09/14] =?UTF-8?q?fix(market):=20topCompetitors=20useMemo?= =?UTF-8?q?=20=EB=88=84=EB=9D=BD=EC=9C=BC=EB=A1=9C=20=EB=A7=88=EC=BB=A4=20?= =?UTF-8?q?=EB=AC=B4=ED=95=9C=20cleanup=20=E2=86=92=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EB=AF=B8=ED=91=9C=EC=8B=9C=20=ED=9A=8C=EA=B7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회귀: MarketTab 의 topCompetitors 가 IIFE 라 매 렌더마다 새 배열 참조 생성. → MapSection.topCompetitorsForMap useMemo 매 렌더 새 배열 → MarketMap useEffect deps (topCompetitors) 매 렌더 변경 → useEffect 재실행 → cleanup 으로 모든 마커 setMap(null) → 재생성 직전 또 deps 바뀜 → 마커 그려지자마자 지워지는 무한 루프 → 화면에 빨간 삼각형/노란 핀 모두 안 뜸 Fix: useMemo([simResult, _spot1.lat, _spot1.lng]) 로 안정 참조. simResult 동일하면 동일 배열 참조 유지 → useEffect 재실행 안 일어남. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SimulationResult/dashboard/tabs/MarketTab.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx b/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx index fcc9002d..3fbcab8d 100644 --- a/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx +++ b/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx @@ -5,6 +5,7 @@ * 3) 하단 풀와이드: 법률 리스크 (InsightsGrid legalOnly) */ +import { useMemo } from 'react'; import { AlertTriangle, Layers, @@ -142,7 +143,10 @@ export function MarketTab({ simResult }: Props) { // 동 한눈에 / 공실률 등 frontend 가 winner 동 데이터 직접 참조하던 곳에서 사용. const analysisDong = _spot1Info?.dongName ?? _winnerForTop ?? null; - const topCompetitors = (() => { + // useMemo 필수 — IIFE 로 매 렌더마다 새 배열 참조 만들면 MapSection → MarketMap 의 useEffect deps + // 가 매 렌더 변경 → useEffect 재실행 → 마커 cleanup→재생성 무한 루프로 마커가 화면에서 깜빡 사라짐. + // simResult / _spot1 좌표만 진짜 의존성. samples 는 simResult 에서 파생되니 simResult 만 추적. + const topCompetitors = useMemo(() => { if (!_spot1) { return [...samples] .filter((s) => s.place_name) @@ -157,7 +161,6 @@ export function MarketTab({ simResult }: Props) { lon?: number | null; distance_m?: number | null; }; - // samples (lat/lon or lat/lng 둘 다 가능) + all_competitor_locations 통합 const allLocations = (simResult.all_competitor_locations ?? []) as CandidateRaw[]; const merged: CandidateRaw[] = [...samples, ...allLocations]; const seen = new Set(); @@ -181,7 +184,8 @@ export function MarketTab({ simResult }: Props) { }) .sort((a, b) => a.distance_m - b.distance_m); return sorted.slice(0, 5); - })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [simResult, _spot1?.lat, _spot1?.lng]); return (
From 6a5c30c9908cf0815478a000e7fabaf761ffa76b Mon Sep 17 00:00:00 2001 From: yejin Date: Wed, 6 May 2026 16:13:38 +0900 Subject: [PATCH 10/14] =?UTF-8?q?feat(market):=20all=5Fcompetitor=5Flocati?= =?UTF-8?q?ons=20=EC=88=98=EC=A7=91=20=EA=B8=B0=EC=A4=80=EC=9D=84=20?= =?UTF-8?q?=EA=B3=B5=EC=8B=A4=20spot=201=EC=9C=84=20=EC=A2=8C=ED=91=9C?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 요구: 사용자 보고 — 풀이 4동 centroid 기준 1.5km 라 spot 1위 좌표 가장자리 매장이 cap 또는 race condition 으로 누락. spot 좌표 기준으로 모든 분석 일치 필요. 변경: · _resolve_spot1_coord(result) helper 추가 — vacancy_spots 의 score 1위 좌표 추출 (graph._resolve_spot_dong / frontend buildBestVacancies 와 동일 규칙) · _query_kakao_store_by_coord(lat, lon, keyword, radius_m, limit) helper 추가 bounding box → haversine 정확 필터 → ORDER BY kakao_id 결정성 + (distance, id) tie-break · _collect_all_competitor_locations 시그니처 확장: spot_lat/spot_lon 인자 있으면 spot 좌표 단일 검색 (1.5km, top 400) 없으면 fallback: 4동 centroid 분기 (구버전 호환, set→sorted 결정성) · 호출처 4곳 (predict / analyze/llm / analyze/llm/async / simulate) 에서 _resolve_spot1_coord 로 spot 좌표 추출 후 인자 전달 효과: · 풀 자체가 spot 좌표 기준 → 가장자리 매장 누락 없음 · 4동 centroid race condition (asyncio.gather + seen_ids) 자동 제거 · spot 좌표 결정적 (Fix A 효과) + SQL ORDER BY 결정적 → 시뮬마다 동일 결과 · cap 100→400 으로 spot 좌표 1.5km 안 동종 매장 모두 포함 (마포 카페 ~340개 커버) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/main.py | 169 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 156 insertions(+), 13 deletions(-) diff --git a/backend/src/main.py b/backend/src/main.py index e4a8a2f4..b18c4b8c 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -313,24 +313,159 @@ def _validate_and_resolve_brand( # config/business_type_mapping.kakao_keyword_of() 사용 — 단일 source of truth. +def _resolve_spot1_coord(result: dict) -> tuple[float, float] | None: + """vacancy_spots 중 spot 1위 좌표 (lat, lon) 반환. + + 선정 규칙 (graph._resolve_spot_dong / frontend buildBestVacancies 와 동일): + 1) winner 동 spot 중 score 1위 (없으면 listing_count 1위) + 2) top3 동 spot 중 listing_count 1위 (winner 동 매물 0건일 때) + 3) 둘 다 없으면 None + """ + spots = result.get("vacancy_spots") or [] + if not isinstance(spots, list) or not spots: + return None + winner = result.get("winner_district") or result.get("target_district") + top3 = result.get("top_3_candidates") or [] + + def _is_valid(s: dict) -> bool: + lat, lon = s.get("lat"), s.get("lon") + return isinstance(lat, (int, float)) and isinstance(lon, (int, float)) + + winner_spots = [s for s in spots if isinstance(s, dict) and s.get("dong_name") == winner and _is_valid(s)] + if winner_spots: + winner_spots.sort( + key=lambda s: ( + -(s.get("score") if isinstance(s.get("score"), (int, float)) else float("-inf")), + -(s.get("listing_count") or 0), + ) + ) + return float(winner_spots[0]["lat"]), float(winner_spots[0]["lon"]) + top3_set = set(top3) + top3_spots = [ + s for s in spots + if isinstance(s, dict) and s.get("dong_name") in top3_set and s.get("dong_name") != winner and _is_valid(s) + ] + if top3_spots: + top3_spots.sort(key=lambda s: -(s.get("listing_count") or 0)) + return float(top3_spots[0]["lat"]), float(top3_spots[0]["lon"]) + return None + + +def _query_kakao_store_by_coord( + center_lat: float, center_lon: float, keyword: str, radius_m: int, limit: int +) -> list[dict]: + """spot 좌표 기준 kakao_store 동종 매장 검색 (analyze_competition 의 좌표 기반 변형). + + bounding box → haversine 정확 필터 → 거리순 정렬 → 상위 limit 개. + SQL 에 ORDER BY kakao_id 명시 (PG 자연순서 비결정 차단). + 프론트 관례에 맞춰 lon → lng rename. + """ + import math + import os + from sqlalchemy import text + + from src.database.connection import get_sync_engine + + # bounding box (작은 영역에서 평면 근사) + deg_lat = radius_m / 111_000.0 + deg_lon = radius_m / (111_000.0 * max(math.cos(math.radians(center_lat)), 1e-6)) + lat_min, lat_max = center_lat - deg_lat, center_lat + deg_lat + lon_min, lon_max = center_lon - deg_lon, center_lon + deg_lon + + sql = text( + """ + SELECT kakao_id, place_name, brand_name, category, + 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 + AND (category ILIKE :kw OR category_detail ILIKE :kw) + ORDER BY kakao_id + """ + ) + engine = get_sync_engine(os.environ["POSTGRES_URL"]) + with engine.connect() as conn: + rows = ( + conn.execute( + sql, + {"lat_min": lat_min, "lat_max": lat_max, "lon_min": lon_min, "lon_max": lon_max, "kw": f"%{keyword}%"}, + ) + .mappings() + .all() + ) + + def _haversine(lat1, lon1, lat2, lon2): + R = 6_371_000.0 + rlat1, rlat2 = math.radians(lat1), math.radians(lat2) + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) + + within: list[dict] = [] + for r in rows: + d = _haversine(center_lat, center_lon, r["lat"], r["lon"]) + if d <= radius_m: + within.append( + { + "id": r["kakao_id"] or f"{r['place_name']}_{r['lat']}_{r['lon']}", + "place_name": r["place_name"] or "", + "brand_name": r["brand_name"] or "", + "lat": r["lat"], + "lng": r["lon"], + "distance_m": round(d, 1), + "is_franchise": bool(r["is_franchise"]), + "category": r["category"] or "", + "place_url": r["place_url"], + "phone": r["phone"], + "source_dong": None, # spot 좌표 기준이라 source 동 무관 + } + ) + within.sort(key=lambda x: (x["distance_m"], x["id"])) # 거리 + id tie-breaker + return within[:limit] + + async def _collect_all_competitor_locations( winner: str, top3: list, business_type: str, + *, + spot_lat: float | None = None, + spot_lon: float | None = None, + spot_radius_m: int = 1500, + spot_limit: int = 400, ) -> list[dict]: - """winner + top3 추천 동 각각의 500m 반경 경쟁업체 좌표를 수집해 통합 반환. + """경쟁업체 좌표 수집 — 지도 마커용. - 2026-05-04 (B1 의뢰서 #3): 좌표 키 mismatch 회귀 fix. - `commercial_intelligence.analyze_competition` 가 samples dict 의 'lon' 키를 - 'lng' 로 rename(line 146)해서 반환하는데, 본 함수는 'lon' 으로 조회하고 있었음. - → 모든 80개 샘플이 좌표 None 으로 인식 → 좌표 필터 통과 0개 → 최종 0개. - 'lng' 로 정합성 맞추고 단계별 로깅 추가하여 회귀 조기 감지. + 분기: + · spot_lat/lon 있으면 → 공실 spot 1위 좌표 단일 기준 1.5km 반경 검색 + (사용자 요청: "모든 분석 기준을 공실 spot 으로 통일") + · 없으면 → fallback: winner+top3 4동 centroid 1.5km 검색 (구버전 호환) + + spot 분기는: + - 풀 자체가 spot 좌표 기준 → 가장자리 매장 누락 없음 + - 4동 centroid race condition (asyncio.gather + seen_ids) 자동 제거 + - SQL 에 ORDER BY kakao_id 명시 → 결정적 """ from src.config.business_type_mapping import kakao_keyword_of keyword = kakao_keyword_of(business_type) or business_type - districts = list({winner} | set(top3 or [])) - print(f"[all_competitors] 수집 시작 — business_type={business_type} keyword={keyword} districts={districts}") + + # spot 좌표 모드 — 모든 기준 통일 + if spot_lat is not None and spot_lon is not None: + print( + f"[all_competitors:spot] 좌표 기준 검색 — ({spot_lat:.5f},{spot_lon:.5f}) " + f"keyword={keyword} radius={spot_radius_m}m limit={spot_limit}" + ) + rows = await asyncio.to_thread( + _query_kakao_store_by_coord, spot_lat, spot_lon, keyword, spot_radius_m, spot_limit + ) + print(f"[all_competitors:spot] 최종 {len(rows)}개") + return rows + + # fallback — 4동 centroid 분기 (spot 좌표 결정 못 한 경우) + districts = sorted({winner} | set(top3 or [])) # set ordering 결정성 + print(f"[all_competitors:fallback] 4동 centroid 검색 — districts={districts}") results: list[dict] = [] seen_ids: set = set() # 단계별 카운터 — raw → dedupe drop → coord drop → 최종 @@ -1038,9 +1173,11 @@ async def analyze_location( # 추천 동 전체(winner + top3)의 경쟁업체 좌표 수집 — 지도 멀티핀용 winner = result.get("winner_district") or input_data.target_district top3 = result.get("top_3_candidates") or [] - print(f"[all_competitors] winner={winner}, top3={top3}") + _spot_coord = _resolve_spot1_coord(result) + print(f"[all_competitors] winner={winner}, top3={top3}, spot1={_spot_coord}") + _spot_lat, _spot_lon = _spot_coord if _spot_coord else (None, None) result["all_competitor_locations"] = await _collect_all_competitor_locations( - winner, top3, input_data.business_type + winner, top3, input_data.business_type, spot_lat=_spot_lat, spot_lon=_spot_lon ) result["same_brand_locations"] = await _collect_same_brand_locations( winner, top3, input_data.brand_name, input_data.business_type @@ -1107,9 +1244,11 @@ async def analyze_llm( # 경쟁업체 좌표 수집 (지도 멀티핀용) — winner 기준 winner = full.get("winner_district") or input_data.target_district top3 = full.get("top_3_candidates") or [] + _spot_coord = _resolve_spot1_coord(full) + _spot_lat, _spot_lon = _spot_coord if _spot_coord else (None, None) try: full["all_competitor_locations"] = await _collect_all_competitor_locations( - winner, top3, input_data.business_type + winner, top3, input_data.business_type, spot_lat=_spot_lat, spot_lon=_spot_lon ) except Exception as e: print(f"[ANALYZE/LLM] all_competitor_locations 수집 실패 (무시): {e}") @@ -1208,9 +1347,11 @@ async def _run() -> None: full = map_state_to_simulation_output(final_state, request_id) winner = full.get("winner_district") or input_data.target_district top3 = full.get("top_3_candidates") or [] + _spot_coord = _resolve_spot1_coord(full) + _spot_lat, _spot_lon = _spot_coord if _spot_coord else (None, None) try: full["all_competitor_locations"] = await _collect_all_competitor_locations( - winner, top3, input_data.business_type + winner, top3, input_data.business_type, spot_lat=_spot_lat, spot_lon=_spot_lon ) except Exception as ce: logger.warning(f"[/analyze/llm/async] all_competitor_locations 실패 (무시): {ce}") @@ -2040,8 +2181,10 @@ async def run_simulation( logger.warning(f"[/simulate] same_brand_locations 실패 (무시): {ce}") result["same_brand_locations"] = [] try: + _spot_coord = _resolve_spot1_coord(result) + _spot_lat, _spot_lon = _spot_coord if _spot_coord else (None, None) result["all_competitor_locations"] = await _collect_all_competitor_locations( - winner, top3, input_data.business_type + winner, top3, input_data.business_type, spot_lat=_spot_lat, spot_lon=_spot_lon ) except Exception as ce: print(f"[SIMULATE] all_competitor_locations 수집 실패 (무시): {ce}") From 4e27f01d7c59f34fddfea0181d866bebd237cf71 Mon Sep 17 00:00:00 2001 From: yejin Date: Wed, 6 May 2026 16:38:46 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat(market):=204=20spot=20=EB=AA=A8?= =?UTF-8?q?=EB=91=90=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EA=B2=BD?= =?UTF-8?q?=EC=9F=81=EC=A0=90=20=EB=82=B4=EB=B6=80/=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EB=B6=84=EB=A5=98=20+=20=ED=92=80=20=ED=95=A9=EC=A7=91?= =?UTF-8?q?=ED=95=A9=20=EC=88=98=EC=A7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 요구: 사용자 — "공실 spot 1,2,3,4 모두 기준으로 경쟁점 내부/외부 표시" backend (main.py): · _resolve_top_spot_coords(result, max_n=4) helper — 4개 spot 좌표 list 반환 (frontend buildBestVacancies 와 동일: winner score 정렬 → top3 listing_count → 50m dedup) · _collect_all_competitor_locations 에 spot_coords list 인자 추가 각 spot 별 1.5km × top 400 검색 후 합집합 + dedup (kakao_id 기준) 결과 정렬: spot1 좌표 기준 거리순 (frontend topCompetitors 와 정합) · 호출처 4곳 (predict / analyze/llm / analyze/llm/async / simulate) 모두 spot_coords 전달 frontend: · MarketMap within 판정 = 4 spot 중 최단 거리 ≤ radius 면 내부 targetSpots 그대로 사용 (Layer 4 에서 2~4위 반경원 그릴 때 쓰는 prop 재활용) · MapSection legend "내부 X / 총 Y" 카운트도 동일 4 spot union 기준 효과: · 4 spot 어느 하나라도 500m 안 매장 = 진한 빨간 삼각형 (내부) · 풀 합집합으로 spot2,3,4 가장자리 매장도 포함 (이전 spot1 단일 1.5km 누락 해소) · 결정성 유지 — _resolve_top_spot_coords + spot_coords list 모두 결정적, ORDER BY id Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/main.py | 133 ++++++++++++------ .../SimulationResult/sections/MapSection.tsx | 12 +- .../SimulationResult/sections/MarketMap.tsx | 13 +- 3 files changed, 111 insertions(+), 47 deletions(-) diff --git a/backend/src/main.py b/backend/src/main.py index b18c4b8c..6a973467 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -313,17 +313,17 @@ def _validate_and_resolve_brand( # config/business_type_mapping.kakao_keyword_of() 사용 — 단일 source of truth. -def _resolve_spot1_coord(result: dict) -> tuple[float, float] | None: - """vacancy_spots 중 spot 1위 좌표 (lat, lon) 반환. +def _resolve_top_spot_coords(result: dict, max_n: int = 4) -> list[tuple[float, float]]: + """vacancy_spots 중 top N 좌표 list 반환 (frontend buildBestVacancies 와 동일 로직). - 선정 규칙 (graph._resolve_spot_dong / frontend buildBestVacancies 와 동일): - 1) winner 동 spot 중 score 1위 (없으면 listing_count 1위) - 2) top3 동 spot 중 listing_count 1위 (winner 동 매물 0건일 때) - 3) 둘 다 없으면 None + 선정 규칙: + A) winner 동 spot → score 정렬 (tie-breaker: listing_count) + B) top3 동 spot → listing_count 정렬 + C) A + B 합집합 → 50m 근접 dedup → 상위 max_n 개 """ spots = result.get("vacancy_spots") or [] if not isinstance(spots, list) or not spots: - return None + return [] winner = result.get("winner_district") or result.get("target_district") top3 = result.get("top_3_candidates") or [] @@ -331,24 +331,45 @@ def _is_valid(s: dict) -> bool: lat, lon = s.get("lat"), s.get("lon") return isinstance(lat, (int, float)) and isinstance(lon, (int, float)) + def _haversine(a: tuple[float, float], b: tuple[float, float]) -> float: + import math + R = 6_371_000.0 + rlat1, rlat2 = math.radians(a[0]), math.radians(b[0]) + dlat = math.radians(b[0] - a[0]) + dlon = math.radians(b[1] - a[1]) + h = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2 + return 2 * R * math.asin(math.sqrt(h)) + winner_spots = [s for s in spots if isinstance(s, dict) and s.get("dong_name") == winner and _is_valid(s)] - if winner_spots: - winner_spots.sort( - key=lambda s: ( - -(s.get("score") if isinstance(s.get("score"), (int, float)) else float("-inf")), - -(s.get("listing_count") or 0), - ) + winner_spots.sort( + key=lambda s: ( + -(s.get("score") if isinstance(s.get("score"), (int, float)) else float("-inf")), + -(s.get("listing_count") or 0), ) - return float(winner_spots[0]["lat"]), float(winner_spots[0]["lon"]) + ) top3_set = set(top3) top3_spots = [ s for s in spots if isinstance(s, dict) and s.get("dong_name") in top3_set and s.get("dong_name") != winner and _is_valid(s) ] - if top3_spots: - top3_spots.sort(key=lambda s: -(s.get("listing_count") or 0)) - return float(top3_spots[0]["lat"]), float(top3_spots[0]["lon"]) - return None + top3_spots.sort(key=lambda s: -(s.get("listing_count") or 0)) + + candidates = [(float(s["lat"]), float(s["lon"])) for s in (winner_spots + top3_spots)] + DEDUP_RADIUS_M = 50.0 + deduped: list[tuple[float, float]] = [] + for cand in candidates: + if any(_haversine(kept, cand) <= DEDUP_RADIUS_M for kept in deduped): + continue + deduped.append(cand) + if len(deduped) >= max_n: + break + return deduped + + +def _resolve_spot1_coord(result: dict) -> tuple[float, float] | None: + """spot 1위 좌표 (구버전 호환 wrapper).""" + coords = _resolve_top_spot_coords(result, max_n=1) + return coords[0] if coords else None def _query_kakao_store_by_coord( @@ -432,26 +453,62 @@ async def _collect_all_competitor_locations( *, spot_lat: float | None = None, spot_lon: float | None = None, + spot_coords: list[tuple[float, float]] | None = None, spot_radius_m: int = 1500, spot_limit: int = 400, ) -> list[dict]: """경쟁업체 좌표 수집 — 지도 마커용. - 분기: - · spot_lat/lon 있으면 → 공실 spot 1위 좌표 단일 기준 1.5km 반경 검색 - (사용자 요청: "모든 분석 기준을 공실 spot 으로 통일") - · 없으면 → fallback: winner+top3 4동 centroid 1.5km 검색 (구버전 호환) + 분기 우선순위: + · spot_coords (4 spot list) → 각 spot 별 1.5km 검색 후 합집합 + dedup + (사용자 요청: "공실 spot 1~4 모두 기준으로 경쟁점 표시") + · spot_lat/lon (단일 spot) → 단일 검색 (구버전 호환) + · 둘 다 없으면 → fallback: winner+top3 4동 centroid 1.5km 검색 - spot 분기는: - - 풀 자체가 spot 좌표 기준 → 가장자리 매장 누락 없음 - - 4동 centroid race condition (asyncio.gather + seen_ids) 자동 제거 - - SQL 에 ORDER BY kakao_id 명시 → 결정적 + SQL 에 ORDER BY kakao_id 명시 → 결정적. spot 좌표 list 도 결정적이라 + 합집합 결과의 dedup ordering 도 입력 spot 순서 (= score 순) 결정. """ from src.config.business_type_mapping import kakao_keyword_of keyword = kakao_keyword_of(business_type) or business_type - # spot 좌표 모드 — 모든 기준 통일 + # 4 spot 모드 — 각 spot 별 1.5km 풀 합집합 (가장 우선) + if spot_coords: + print( + f"[all_competitors:spots] {len(spot_coords)} spot 좌표 기준 검색 — " + f"keyword={keyword} per-spot radius={spot_radius_m}m limit={spot_limit}" + ) + merged: list[dict] = [] + seen_ids: set = set() + for idx, (s_lat, s_lon) in enumerate(spot_coords, 1): + rows = await asyncio.to_thread( + _query_kakao_store_by_coord, s_lat, s_lon, keyword, spot_radius_m, spot_limit + ) + print(f"[all_competitors:spots] spot{idx} ({s_lat:.5f},{s_lon:.5f}) → {len(rows)}개") + for r in rows: + rid = r.get("id") + if rid in seen_ids: + continue + seen_ids.add(rid) + merged.append(r) + # 결정적 정렬 — 1번 spot 좌표 기준 거리순 (frontend 도 spot1 기준 haversine 으로 sort 하니 정합성). + if spot_coords: + anchor_lat, anchor_lon = spot_coords[0] + import math + + def _hv(la, lo): + R = 6_371_000.0 + rlat1, rlat2 = math.radians(anchor_lat), math.radians(la) + dlat = math.radians(la - anchor_lat) + dlon = math.radians(lo - anchor_lon) + a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) + + merged.sort(key=lambda r: (_hv(r["lat"], r["lng"]), str(r.get("id")))) + print(f"[all_competitors:spots] 최종 합집합 {len(merged)}개 (dedup 후)") + return merged + + # 단일 spot 모드 (구버전 호환) if spot_lat is not None and spot_lon is not None: print( f"[all_competitors:spot] 좌표 기준 검색 — ({spot_lat:.5f},{spot_lon:.5f}) " @@ -1173,11 +1230,10 @@ async def analyze_location( # 추천 동 전체(winner + top3)의 경쟁업체 좌표 수집 — 지도 멀티핀용 winner = result.get("winner_district") or input_data.target_district top3 = result.get("top_3_candidates") or [] - _spot_coord = _resolve_spot1_coord(result) - print(f"[all_competitors] winner={winner}, top3={top3}, spot1={_spot_coord}") - _spot_lat, _spot_lon = _spot_coord if _spot_coord else (None, None) + _spot_coords = _resolve_top_spot_coords(result, max_n=4) + print(f"[all_competitors] winner={winner}, top3={top3}, top_spots={len(_spot_coords)}개") result["all_competitor_locations"] = await _collect_all_competitor_locations( - winner, top3, input_data.business_type, spot_lat=_spot_lat, spot_lon=_spot_lon + winner, top3, input_data.business_type, spot_coords=_spot_coords ) result["same_brand_locations"] = await _collect_same_brand_locations( winner, top3, input_data.brand_name, input_data.business_type @@ -1244,11 +1300,10 @@ async def analyze_llm( # 경쟁업체 좌표 수집 (지도 멀티핀용) — winner 기준 winner = full.get("winner_district") or input_data.target_district top3 = full.get("top_3_candidates") or [] - _spot_coord = _resolve_spot1_coord(full) - _spot_lat, _spot_lon = _spot_coord if _spot_coord else (None, None) + _spot_coords = _resolve_top_spot_coords(full, max_n=4) try: full["all_competitor_locations"] = await _collect_all_competitor_locations( - winner, top3, input_data.business_type, spot_lat=_spot_lat, spot_lon=_spot_lon + winner, top3, input_data.business_type, spot_coords=_spot_coords ) except Exception as e: print(f"[ANALYZE/LLM] all_competitor_locations 수집 실패 (무시): {e}") @@ -1347,11 +1402,10 @@ async def _run() -> None: full = map_state_to_simulation_output(final_state, request_id) winner = full.get("winner_district") or input_data.target_district top3 = full.get("top_3_candidates") or [] - _spot_coord = _resolve_spot1_coord(full) - _spot_lat, _spot_lon = _spot_coord if _spot_coord else (None, None) + _spot_coords = _resolve_top_spot_coords(full, max_n=4) try: full["all_competitor_locations"] = await _collect_all_competitor_locations( - winner, top3, input_data.business_type, spot_lat=_spot_lat, spot_lon=_spot_lon + winner, top3, input_data.business_type, spot_coords=_spot_coords ) except Exception as ce: logger.warning(f"[/analyze/llm/async] all_competitor_locations 실패 (무시): {ce}") @@ -2181,10 +2235,9 @@ async def run_simulation( logger.warning(f"[/simulate] same_brand_locations 실패 (무시): {ce}") result["same_brand_locations"] = [] try: - _spot_coord = _resolve_spot1_coord(result) - _spot_lat, _spot_lon = _spot_coord if _spot_coord else (None, None) + _spot_coords = _resolve_top_spot_coords(result, max_n=4) result["all_competitor_locations"] = await _collect_all_competitor_locations( - winner, top3, input_data.business_type, spot_lat=_spot_lat, spot_lon=_spot_lon + winner, top3, input_data.business_type, spot_coords=_spot_coords ) except Exception as ce: print(f"[SIMULATE] all_competitor_locations 수집 실패 (무시): {ce}") diff --git a/frontend/src/components/SimulationResult/sections/MapSection.tsx b/frontend/src/components/SimulationResult/sections/MapSection.tsx index b5869249..01a27cd4 100644 --- a/frontend/src/components/SimulationResult/sections/MapSection.tsx +++ b/frontend/src/components/SimulationResult/sections/MapSection.tsx @@ -274,11 +274,13 @@ export function MapSection({ simResult, topCompetitors }: Props) { const effectiveRadius = userRadius ?? 500; const totalCompetitors = competitors.length; - // within 판정 = 화면 핀 좌표(center) 기준 haversine. 백엔드 distance_m 은 source 동 centroid - // 기준이라 핀 위치와 정합 안 됨 → MarketMap 마커 색·legend 카운트 일치시킴. - const withinCompetitors = competitors.filter( - (c) => haversineM(center.lat, center.lng, c.lat, c.lng) <= effectiveRadius, - ).length; + // within 판정 = 4 spot 중 최단거리 ≤ radius 면 내부 (MarketMap 의 within 분기와 동일). + // bestVacancies 4개 좌표 union → 어느 하나라도 반경 안이면 내부. + const _withinSpots = bestVacancies.length > 0 ? bestVacancies : [center]; + const withinCompetitors = competitors.filter((c) => { + const minDist = Math.min(..._withinSpots.map((sp) => haversineM(sp.lat, sp.lng, c.lat, c.lng))); + return minDist <= effectiveRadius; + }).length; const compIntel = simResult.competitor_intel as Record | null | undefined; const saturation = diff --git a/frontend/src/components/SimulationResult/sections/MarketMap.tsx b/frontend/src/components/SimulationResult/sections/MarketMap.tsx index 160d2316..9965c47c 100644 --- a/frontend/src/components/SimulationResult/sections/MarketMap.tsx +++ b/frontend/src/components/SimulationResult/sections/MarketMap.tsx @@ -433,6 +433,12 @@ export function MarketMap({ // 백엔드 c.distance_m 은 source 동 centroid 기준이라 핀과 정합 안 됨 → 무시하고 haversineM 으로 재계산. const withinCenterLat = targetSpot?.lat ?? center.lat; const withinCenterLng = targetSpot?.lng ?? center.lng; + // within 판정 = 4 spot 중 어느 하나라도 radius 안이면 내부 (사용자 요청). + // targetSpots 비어있으면 단일 spot1 좌표만 사용 (구버전 호환). + const allSpots: { lat: number; lng: number }[] = + targetSpots && targetSpots.length > 0 + ? targetSpots + : [{ lat: withinCenterLat, lng: withinCenterLng }]; const normalizedUserBrand = normalizeBrand(userBrand); // sameBrandLocations 와 중복으로 그려지는 자사 매장 좌표 제거용 set (key=lat,lng 4자리). const sameBrandPosKeys = new Set( @@ -475,8 +481,11 @@ export function MarketMap({ const posKey = `${c.lat.toFixed(5)},${c.lng.toFixed(5)}`; if (isSelfBrand && sameBrandPosKeys.has(posKey)) return; - const distFromCenter = haversineM(withinCenterLat, withinCenterLng, c.lat, c.lng); - const within = distFromCenter <= radius; + // 4 spot 중 최단거리 — 하나라도 radius 안이면 within. + const minDistFromAnySpot = Math.min( + ...allSpots.map((sp) => haversineM(sp.lat, sp.lng, c.lat, c.lng)), + ); + const within = minDistFromAnySpot <= radius; const pos = new maps.LatLng(c.lat, c.lng); // 주요 경쟁점 top5 매칭 — 사이드바 카드와 동일 좌표면 큰 번호 라벨로 강조. const top5Rank = topCompetitorRankByPos.get(`${c.lat.toFixed(4)},${c.lng.toFixed(4)}`); From 7121753ef5c20caff64bc7726dfedbc2a329a2e2 Mon Sep 17 00:00:00 2001 From: yejin Date: Wed, 6 May 2026 16:46:56 +0900 Subject: [PATCH 12/14] =?UTF-8?q?fix(market):=20=ED=92=80=20cap=20200=20?= =?UTF-8?q?=E2=86=92=201000=20=E2=80=94=20spot=202,3,4=20=EC=A3=BC?= =?UTF-8?q?=EB=B3=80=20=EB=A7=A4=EC=9E=A5=EC=9D=B4=20spot1=20=EA=B1=B0?= =?UTF-8?q?=EB=A6=AC=EC=88=9C=20=EC=A0=95=EB=A0=AC=EC=97=90=20=EC=9E=98?= =?UTF-8?q?=EB=A0=A4=20=EB=88=84=EB=9D=BD=EB=90=9C=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: 4 spot 합집합 풀이 backend 에서 spot1 좌표 거리순 정렬로 반환되는데, frontend buildCompetitors cap 200 이 spot1 가까운 매장 200개만 유지 → spot 2,3,4 주변 매장은 spot1 기준 멀어서 cap 에 잘려 지도에서 사라짐. Fix: · frontend buildCompetitors cap 200 → 1000 (4 spot × 1.5km 합집합 모두 보존) · backend spot_limit 400 → 800 (한 spot 1.5km 안 카페가 400개 넘는 동 대비) 마포 카페 전체가 수백개 수준이라 1000 cap 에 도달하는 일은 사실상 없음. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/main.py | 2 +- .../components/SimulationResult/sections/MapSection.tsx | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/src/main.py b/backend/src/main.py index 6a973467..a9b8ca85 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -455,7 +455,7 @@ async def _collect_all_competitor_locations( spot_lon: float | None = None, spot_coords: list[tuple[float, float]] | None = None, spot_radius_m: int = 1500, - spot_limit: int = 400, + spot_limit: int = 800, ) -> list[dict]: """경쟁업체 좌표 수집 — 지도 마커용. diff --git a/frontend/src/components/SimulationResult/sections/MapSection.tsx b/frontend/src/components/SimulationResult/sections/MapSection.tsx index 01a27cd4..6d42c59b 100644 --- a/frontend/src/components/SimulationResult/sections/MapSection.tsx +++ b/frontend/src/components/SimulationResult/sections/MapSection.tsx @@ -81,7 +81,10 @@ function buildCompetitors(simResult: SimulationOutput): Competitor[] { phone: typeof s.phone === 'string' ? s.phone : null, }); } - // dedup — place_name + 좌표(소수 5자리) 동일하면 동일 매장으로 판단 + // dedup — place_name + 좌표(소수 5자리) 동일하면 동일 매장으로 판단. + // cap 200 → 1000 으로 늘림 — backend 가 spot1 거리순 정렬로 보내는데 cap 200 이면 + // spot 2,3,4 주변 매장이 (spot1 기준 멀어서) 잘려나가 화면에 안 뜸. + // 4 spot × 1.5km 합집합이라 1000개 넘는 일은 사실상 없음 (마포 카페 전체 ~수백개). const seen = new Set(); const deduped: Competitor[] = []; for (const c of merged) { @@ -89,7 +92,7 @@ function buildCompetitors(simResult: SimulationOutput): Competitor[] { if (seen.has(key)) continue; seen.add(key); deduped.push(c); - if (deduped.length >= 200) break; + if (deduped.length >= 1000) break; } return deduped; } From 6a7fc604a0eb167ac36dc572fdd81fddfad5752b Mon Sep 17 00:00:00 2001 From: yejin Date: Wed, 6 May 2026 17:16:12 +0900 Subject: [PATCH 13/14] =?UTF-8?q?fix(market):=20=5Fquery=5Fkakao=5Fstore?= =?UTF-8?q?=5Fby=5Fcoord=20import=20=EA=B2=BD=EB=A1=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=E2=80=94=20spot=20=EB=AA=A8=EB=93=9C=20silent=20fa?= =?UTF-8?q?llback=20=EC=9B=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: spot 모드 진입했는데 _query_kakao_store_by_coord 가 ModuleNotFoundError: No module named 'src.database.connection' 발생. 호출처 try/except 가 exception 잡아 all_competitor_locations=[] 반환. frontend 는 samples 20개만 사용하는 else 분기로 빠져 풀이 20개로 보임. Fix: from src.database.connection import get_sync_engine → from src.database.sync_engine import get_sync_engine 실제 모듈 위치 확인 (commercial_intelligence.py 와 동일 import 패턴). Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main.py b/backend/src/main.py index a9b8ca85..b92b97d1 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -385,7 +385,7 @@ def _query_kakao_store_by_coord( import os from sqlalchemy import text - from src.database.connection import get_sync_engine + from src.database.sync_engine import get_sync_engine # bounding box (작은 영역에서 평면 근사) deg_lat = radius_m / 111_000.0 From d04b31e3e8476ae607d52f6c7428d3e01edc571a Mon Sep 17 00:00:00 2001 From: yejin Date: Wed, 6 May 2026 17:26:48 +0900 Subject: [PATCH 14/14] =?UTF-8?q?chore:=20PR=20=ED=8F=AC=ED=95=A8=20?= =?UTF-8?q?=EC=9E=90=ED=88=AC=EB=A6=AC=20=EB=B3=80=EA=B2=BD=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frontend: · MarketMap.tsx — top5 노란 뱃지 클릭 가능 (이전 pointer-events:none 회귀 fix, 같은 경쟁점 popup 열림) · DemographicTab.tsx, FlowVsRevenueScatter.tsx — 이전 세션 demographic 작업분 · types/index.ts — demographic 스키마 타입 확장 backend: · demographic_depth.py — 이전 세션 demographic 분석 로직 변경 · synthesis.py — 이전 세션 변경 · schemas/demographic.py — 이전 세션 스키마 확장 TS check + AST parse 통과. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/agents/nodes/demographic_depth.py | 457 +++++++++++++++++- backend/src/agents/nodes/synthesis.py | 42 +- backend/src/schemas/demographic.py | 46 ++ .../dashboard/charts/FlowVsRevenueScatter.tsx | 8 +- .../dashboard/tabs/DemographicTab.tsx | 232 ++++++++- .../SimulationResult/sections/MarketMap.tsx | 8 +- frontend/src/types/index.ts | 25 + 7 files changed, 801 insertions(+), 17 deletions(-) diff --git a/backend/src/agents/nodes/demographic_depth.py b/backend/src/agents/nodes/demographic_depth.py index 9328b436..14f80f7e 100644 --- a/backend/src/agents/nodes/demographic_depth.py +++ b/backend/src/agents/nodes/demographic_depth.py @@ -22,6 +22,8 @@ CoreDemographic, DemographicAnalysis, DemographicReport, + ReverseTargetSuggestion, + TargetAlignmentAlert, ) from src.schemas.state import AgentState @@ -145,6 +147,414 @@ def _build_prompt( return "".join(parts) +# --------------------------------------------------------------------------- +# 사용자 타겟 vs 실측 분포 정렬 평가 +# --------------------------------------------------------------------------- +# 사용자 입력 형식 → DB age_breakdown 키 매핑. 한국어 "30대" / 영문 "30s" 양쪽 흡수. +_AGE_INPUT_TO_KEY: dict[str, str] = { + "10대": "10", + "20대": "20", + "30대": "30", + "40대": "40", + "50대": "50", + "60대": "60+", + "60대+": "60+", + "60+": "60+", + "10s": "10", + "20s": "20", + "30s": "30", + "40s": "40", + "50s": "50", + "60s": "60+", +} + +# operating_hours 한국어 카테고리 → time_breakdown 키. UI 가 어떤 라벨을 쓰든 흡수. +_HOURS_LABEL_TO_KEYS: dict[str, list[str]] = { + "새벽": ["00-06"], + "아침": ["06-11"], + "오전": ["06-11"], + "점심": ["11-14"], + "오후": ["14-17"], + "저녁": ["17-21"], + "밤": ["21-24"], + "심야": ["21-24", "00-06"], +} + +# target_time_slots ('time_HH_HH') → time_breakdown 키. +_TIME_SLOT_TO_KEY: dict[str, str] = { + "time_00_06": "00-06", + "time_06_11": "06-11", + "time_11_14": "11-14", + "time_14_17": "14-17", + "time_17_21": "17-21", + "time_21_24": "21-24", +} + +# 객단가 구간 → 추정 income tier. 동의 area_income_level(high/mid/low) 와 비교용. +# 5천원 이하는 박리다매, 1.5만원 이상은 프리미엄. 기준은 마포구 카페·요식 평균(2026 기준 추정). +_PRICE_TO_TIER: dict[str, str] = { + "lt5k": "low", + "5to10k": "mid", + "10to15k": "mid", + "15to20k": "high", + "gt15k": "high", + "gt20k": "high", +} + + +def _normalize_age_inputs(values: list[str]) -> list[str]: + """사용자 타겟 연령 입력을 age_breakdown key 형식으로 정규화. 미상은 제외.""" + out: list[str] = [] + for v in values or []: + key = _AGE_INPUT_TO_KEY.get(str(v).strip()) + if key and key not in out: + out.append(key) + return out + + +def _user_time_keys(operating_hours: list[str], time_slots: list[str]) -> list[str]: + """operating_hours(한국어) + target_time_slots(코드) 합집합을 time_breakdown 키로 변환.""" + keys: set[str] = set() + for h in operating_hours or []: + for k in _HOURS_LABEL_TO_KEYS.get(str(h).strip(), []): + keys.add(k) + for ts in time_slots or []: + k = _TIME_SLOT_TO_KEY.get(str(ts).strip()) + if k: + keys.add(k) + return list(keys) + + +def _age_label(key: str) -> str: + """age_breakdown 키 → 사용자 친화 라벨.""" + return "60대+" if key == "60+" else f"{key}대" + + +def _evaluate_target_alignment( + state: dict, + sales: dict, + core: CoreDemographic, + top3: list[AgeShare], + peak: list[str], + wd_we_ratio: float, + income_level: str, +) -> tuple[list[TargetAlignmentAlert], float | None]: + """사용자 입력 타겟 vs 실측 매출·소득 분포 매칭. + + 각 차원(age/gender/hours/day/price) 별로 0-100 점수 산출 → 평균이 alignment_score. + 점수가 60 미만인 차원은 severity 와 함께 alert 로 노출. 입력이 None/[] 인 차원은 + skip(점수·alert 모두 미반영). + """ + age_groups = state.get("target_age_groups") or [] + gender = state.get("target_gender") + op_hours = state.get("operating_hours") or [] + time_slots = state.get("target_time_slots") or [] + day_type = state.get("target_day_type") + price_range = state.get("target_price_range") + + alerts: list[TargetAlignmentAlert] = [] + scores: list[float] = [] + + # ── age ──────────────────────────────────────────────────────────────── + user_age_keys = _normalize_age_inputs(age_groups) + if user_age_keys: + top_keys = [a.age_group for a in top3] + # 차원 점수 = user 타겟 그룹들의 평균 매칭 점수. + # top1=100, top2=70, top3=50, 외부=share*200(최대 30) + per_user_scores: list[float] = [] + for uk in user_age_keys: + if uk == top_keys[0] if top_keys else False: + per_user_scores.append(100.0) + elif len(top_keys) > 1 and uk == top_keys[1]: + per_user_scores.append(70.0) + elif len(top_keys) > 2 and uk == top_keys[2]: + per_user_scores.append(50.0) + else: + # top3 외 — share 비율로 부분 점수 (최대 30) + age_br = sales.get("age_breakdown", {}) or {} + total = sales.get("monthly_sales", 0) or 1 + outside_share = (age_br.get(uk, 0) or 0) / total + per_user_scores.append(min(30.0, outside_share * 200)) + age_score = sum(per_user_scores) / len(per_user_scores) + scores.append(age_score) + if age_score < 60: + severity = "high" if age_score < 35 else "medium" + top1 = top_keys[0] if top_keys else "unknown" + top1_share = round((top3[0].share * 100) if top3 else 0, 1) + alerts.append( + TargetAlignmentAlert( + dimension="age", + severity=severity, + user_input=", ".join(_age_label(k) for k in user_age_keys), + actual=f"{_age_label(top1)} {top1_share}% (1위)", + message=( + f"입력 타겟({', '.join(_age_label(k) for k in user_age_keys)})이 " + f"실 매출 1위 연령대({_age_label(top1)})와 어긋남." + ), + ) + ) + + # ── gender ───────────────────────────────────────────────────────────── + if gender in ("male", "female"): + if core.gender == "mixed": + gender_score = 70.0 + elif core.gender == gender: + gender_score = 100.0 + else: + gender_score = 20.0 + scores.append(gender_score) + if gender_score < 60: + label_ko = {"male": "남성", "female": "여성", "mixed": "혼재"} + alerts.append( + TargetAlignmentAlert( + dimension="gender", + severity="high" if gender_score < 35 else "medium", + user_input=label_ko.get(gender, gender), + actual=label_ko.get(core.gender, core.gender), + message=( + f"입력 타겟 성별({label_ko.get(gender, gender)})과 " + f"실 소비 주류({label_ko.get(core.gender, core.gender)})가 다름." + ), + ) + ) + + # ── hours ────────────────────────────────────────────────────────────── + user_t_keys = _user_time_keys(op_hours, time_slots) + if user_t_keys and peak: + # 사용자 시간대가 매출 피크 top2 와 얼마나 겹치는지. + overlap = len(set(user_t_keys) & set(peak)) + if overlap >= 2: + hours_score = 100.0 + elif overlap == 1: + hours_score = 70.0 + else: + # 0 — 그래도 user time keys 의 평균 매출 share 로 부분 점수. + tb = sales.get("time_breakdown", {}) or {} + total = sales.get("monthly_sales", 0) or 1 + avg_share = sum((tb.get(k, 0) or 0) for k in user_t_keys) / max(len(user_t_keys), 1) / total + hours_score = min(40.0, avg_share * 200) + scores.append(hours_score) + if hours_score < 60: + alerts.append( + TargetAlignmentAlert( + dimension="hours", + severity="high" if hours_score < 35 else "medium", + user_input=", ".join(sorted(user_t_keys)), + actual=f"피크 {', '.join(peak)}", + message=( + f"입력 영업·타겟 시간대({', '.join(sorted(user_t_keys))})와 " + f"실 매출 피크({', '.join(peak)})가 어긋남." + ), + ) + ) + + # ── day ──────────────────────────────────────────────────────────────── + if day_type in ("weekday", "weekend"): + # weekday_weekend_ratio > 1 → 평일우위, < 1 → 주말우위 + is_weekday_dominant = wd_we_ratio > 1.1 + is_weekend_dominant = wd_we_ratio < 0.9 + match = (day_type == "weekday" and is_weekday_dominant) or (day_type == "weekend" and is_weekend_dominant) + opposite = (day_type == "weekday" and is_weekend_dominant) or (day_type == "weekend" and is_weekday_dominant) + day_score = 100.0 if match else (30.0 if opposite else 65.0) + scores.append(day_score) + if day_score < 60: + actual_dominant = "주말 우위" if is_weekend_dominant else ("평일 우위" if is_weekday_dominant else "균형") + label_ko = {"weekday": "평일", "weekend": "주말"} + alerts.append( + TargetAlignmentAlert( + dimension="day", + severity="high" if day_score < 35 else "medium", + user_input=label_ko.get(day_type, day_type), + actual=f"{actual_dominant} (평일/주말비 {wd_we_ratio})", + message=( + f"입력 타겟 요일({label_ko.get(day_type, day_type)})과 " + f"실 매출 분포({actual_dominant})가 어긋남." + ), + ) + ) + + # ── price ────────────────────────────────────────────────────────────── + if price_range and income_level in ("high", "mid", "low"): + user_tier = _PRICE_TO_TIER.get(str(price_range).strip(), "mid") + # high price + low income, low price + high income 은 미스매치. + # 같은 등급은 100, 한 단계 차이는 70, 두 단계는 30. + order = {"low": 0, "mid": 1, "high": 2} + diff = abs(order[user_tier] - order[income_level]) + price_score = {0: 100.0, 1: 70.0, 2: 30.0}[diff] + scores.append(price_score) + if price_score < 60: + alerts.append( + TargetAlignmentAlert( + dimension="price", + severity="high" if price_score < 35 else "medium", + user_input=f"{price_range} ({user_tier} 가격대)", + actual=f"동 소득 {income_level}", + message=(f"객단가({price_range}, {user_tier} 등급)와 동 소득 수준({income_level})이 어긋남."), + ) + ) + + if not scores: + return [], None + overall = round(sum(scores) / len(scores), 1) + return alerts, overall + + +def _alignment_from_report(state: dict, report: dict) -> tuple[list[dict], float | None]: + """캐시 히트 경로용 — 저장된 report dict 만으로 alignment 재계산. + + 캐시는 base demographic 만 저장하고 사용자 입력은 캐시 키에 포함하지 않음 + (사용자별 키 폭발 방지). 따라서 hit 경로에서도 사용자 입력에 맞춰 alignment 를 + 매번 새로 계산해 report 에 덮어씀. + """ + if not isinstance(report, dict): + return [], None + try: + core_dict = report.get("core_demographic") or {} + core = CoreDemographic( + age=core_dict.get("age", "unknown"), + gender=core_dict.get("gender", "mixed"), + share=float(core_dict.get("share", 0.0) or 0.0), + ) + top3 = [ + AgeShare(age_group=a.get("age_group", ""), share=float(a.get("share", 0.0) or 0.0)) + for a in (report.get("top_3_age_groups") or []) + if isinstance(a, dict) and a.get("age_group") + ] + peak = list(report.get("peak_consumption_hours") or []) + wd_we = float(report.get("weekday_weekend_ratio", 1.0) or 1.0) + income_level = str(report.get("area_income_level", "unknown") or "unknown") + # cached report 에는 raw sales 가 없음 — outside-top3 share fallback 만 영향받고 + # top1/top2/top3 매칭은 그대로 동작. 미스매치 검출엔 충분. + sales_stub = {"age_breakdown": {}, "monthly_sales": 1, "time_breakdown": {}} + alerts, score = _evaluate_target_alignment(state, sales_stub, core, top3, peak, wd_we, income_level) + return [a.model_dump() for a in alerts], score + except Exception as e: + logger.warning("[demographic] alignment from cache 실패 (무시): %s", e) + return [], None + + +# 영문 income_level → 권장 객단가 구간. 가격대 vs 소득 매칭 룰 (_PRICE_TO_TIER) 의 역방향. +_INCOME_TO_PRICE: dict[str, str] = { + "low": "lt5k", + "mid": "5to10k", + "high": "10to15k", +} + +# time_breakdown 키 → 한국어 영업시간 카테고리 (역방향: _HOURS_LABEL_TO_KEYS). +# 한 키가 여러 라벨에 매핑될 수 있어 "가장 일반적인" 단일 라벨로 매핑. +_TIME_KEY_TO_LABEL: dict[str, str] = { + "00-06": "심야", + "06-11": "아침", + "11-14": "점심", + "14-17": "오후", + "17-21": "저녁", + "21-24": "밤", +} + + +def _build_reverse_suggestion( + alerts: list[TargetAlignmentAlert], + core: CoreDemographic, + top3: list[AgeShare], + peak: list[str], + wd_we_ratio: float, + income_level: str, +) -> ReverseTargetSuggestion | None: + """alert 중 high 가 1개 이상이면 실측 기반 권장 타겟 프로필 생성. + + "이 입지를 그대로 두고 타겟·운영전략을 바꿔서 정렬도 90+ 만들려면 이렇게" 의 + 역제안. 입지가 고정일 때 사용자가 객단가·운영시간·홍보 타겟을 재정의할 + 근거로 활용. + """ + has_high = any(a.severity == "high" for a in alerts) + if not has_high: + return None + + # 권장 연령 — 매출 상위 1-2개 (top3 중 share 5% 이상만). + rec_ages: list[str] = [] + for a in top3[:2]: + if a.share >= 0.05: + rec_ages.append(_age_label(a.age_group)) + + # 권장 성별 — core 가 mixed 면 None. + rec_gender: str | None = None + if core.gender in ("male", "female"): + rec_gender = core.gender + + # 권장 시간대 — 피크 top2 를 한국어 라벨로. + rec_hours: list[str] = [] + seen: set[str] = set() + for tk in peak[:2]: + label = _TIME_KEY_TO_LABEL.get(tk) + if label and label not in seen: + rec_hours.append(label) + seen.add(label) + + # 권장 요일 — wd_we_ratio 로 판정. + rec_day: str | None = None + if wd_we_ratio > 1.1: + rec_day = "weekday" + elif wd_we_ratio < 0.9: + rec_day = "weekend" + + # 권장 객단가 — 동 income_level 매핑. + rec_price = _INCOME_TO_PRICE.get(income_level) + + # rationale — 실측 근거 1-2 문장. + parts: list[str] = [] + if rec_ages: + parts.append(f"매출 상위 연령대 {', '.join(rec_ages)}") + if rec_hours: + parts.append(f"피크 시간대 {', '.join(rec_hours)}") + if rec_day: + parts.append("평일 우위" if rec_day == "weekday" else "주말 우위") + if rec_price: + parts.append(f"동 소득 {income_level} → {rec_price} 객단가") + base = " · ".join(parts) if parts else "실측 분포" + rationale = f"이 입지의 실 소비층은 {base}. 타겟·운영전략을 이에 맞춰 재정의하면 정렬도 향상 가능." + + return ReverseTargetSuggestion( + recommended_age_groups=rec_ages, + recommended_gender=rec_gender, + recommended_hours=rec_hours, + recommended_day_type=rec_day, + recommended_price_range=rec_price, + rationale=rationale, + ) + + +def _reverse_from_report(report: dict, alerts_dicts: list[dict]) -> dict | None: + """캐시 hit 경로용 — report dict + 새로 계산된 alerts dict 로 역제안 생성.""" + if not alerts_dicts: + return None + try: + core_dict = report.get("core_demographic") or {} + core = CoreDemographic( + age=core_dict.get("age", "unknown"), + gender=core_dict.get("gender", "mixed"), + share=float(core_dict.get("share", 0.0) or 0.0), + ) + top3 = [ + AgeShare(age_group=a.get("age_group", ""), share=float(a.get("share", 0.0) or 0.0)) + for a in (report.get("top_3_age_groups") or []) + if isinstance(a, dict) and a.get("age_group") + ] + peak = list(report.get("peak_consumption_hours") or []) + wd_we = float(report.get("weekday_weekend_ratio", 1.0) or 1.0) + income_level = str(report.get("area_income_level", "unknown") or "unknown") + # alerts_dicts → TargetAlignmentAlert 로 복원 + alerts_objs = [ + TargetAlignmentAlert(**a) + for a in alerts_dicts + if isinstance(a, dict) and a.get("dimension") and a.get("severity") + ] + sug = _build_reverse_suggestion(alerts_objs, core, top3, peak, wd_we, income_level) + return sug.model_dump() if sug else None + except Exception as e: + logger.warning("[demographic] reverse suggestion 생성 실패 (무시): %s", e) + return None + + def _make_empty_report(dong_name: str, brand_name: str | None) -> dict: return DemographicReport( core_demographic=CoreDemographic(age="unknown", gender="mixed", share=0.0), @@ -167,7 +577,9 @@ async def demographic_depth_node(state: AgentState) -> dict: brand_name = state.get("brand_name") industry_filter = state.get("industry_filter") - cache_key = f"v3:demographic:{brand_name or 'nobrand'}:{dong_code}:{industry_filter or 'all'}" + # v4: target_alignment 필드 추가 (base report 만 캐시, alignment 는 사용자 입력별 fresh). + # v5: reverse_target_suggestion 필드 추가 (사용자 입력별 fresh, 캐시 무관). + cache_key = f"v5:demographic:{brand_name or 'nobrand'}:{dong_code}:{industry_filter or 'all'}" _redis = None try: _redis = aioredis.from_url(settings.redis_url, decode_responses=True) @@ -176,6 +588,14 @@ async def demographic_depth_node(state: AgentState) -> dict: print(f"[demographic] 캐시 히트: {cache_key}") analysis = dict(state.get("analysis_results", {}) or {}) _cached_report = json.loads(cached) + # 사용자 입력 타겟 정렬은 캐시 키에 안 포함 — hit 경로에서 매번 fresh 계산. + _alerts: list = [] + _ascore: float | None = None + if isinstance(_cached_report, dict): + _alerts, _ascore = _alignment_from_report(state, _cached_report) + _cached_report["target_alignment"] = _alerts + _cached_report["target_alignment_score"] = _ascore + _cached_report["reverse_target_suggestion"] = _reverse_from_report(_cached_report, _alerts) analysis["demographic_report"] = _cached_report await _redis.aclose() _core = _cached_report.get("core_demographic") if isinstance(_cached_report, dict) else None @@ -186,6 +606,9 @@ async def demographic_depth_node(state: AgentState) -> dict: _share_pct = round(float(_share_raw) * 100, 1) except Exception: _share_pct = 0 + _cached_verdict = f"주 소비층 {_age} {_gender} ({_share_pct}%)" + if isinstance(_ascore, (int, float)): + _cached_verdict += f" · 타겟 정렬 {_ascore:.0f}/100" cached_demo_attr = build_attribution( agent_id="demographic_depth", display_name="인구 심층분석", @@ -196,7 +619,7 @@ async def demographic_depth_node(state: AgentState) -> dict: "kosis_regional_income", "elderly_ratio_region", ], - verdict=f"주 소비층 {_age} {_gender} ({_share_pct}%)", + verdict=_cached_verdict, reasoning=(_cached_report.get("narrative", "") if isinstance(_cached_report, dict) else "") or "소비자 심층 분석 (캐시)", confidence=0.85, @@ -346,21 +769,36 @@ def _safe(x, default): if rr is not None and vr is not None and (rr + vr) > 0: rv_ratio = round(vr / (rr + vr), 3) - report = DemographicReport( + # 사용자 입력 타겟 정렬 평가 (실측 기반 — 캐시 저장본은 base report 만, alignment 는 + # 항상 사용자 입력 따라 fresh 계산. 캐시 키에 사용자 입력 포함하지 않는 이유.) + income_level = str(context.get("income_level", "unknown") or "unknown") + alignment_alerts, alignment_score = _evaluate_target_alignment(state, sales, core, top3, peak, wd_we, income_level) + reverse_suggestion = _build_reverse_suggestion(alignment_alerts, core, top3, peak, wd_we, income_level) + + base_report = DemographicReport( core_demographic=core, top_3_age_groups=top3, peak_consumption_hours=peak, weekday_weekend_ratio=wd_we, resident_visitor_ratio=rv_ratio, - area_income_level=context.get("income_level", "unknown"), + area_income_level=income_level, population_trend=context.get("population_trend", "unknown"), elderly_ratio=context.get("elderly_ratio"), brand_target_match_score=analysis_out.brand_target_match_score if brand_name else None, match_rationale=analysis_out.match_rationale if brand_name else None, narrative=analysis_out.narrative, + target_alignment=alignment_alerts, + target_alignment_score=alignment_score, + reverse_target_suggestion=reverse_suggestion, ).model_dump() - # 캐시 저장 + # 캐시는 alignment + 역제안 제외한 base 만 저장 — 사용자 입력별 키 폭발 방지. + cacheable_report = { + **base_report, + "target_alignment": [], + "target_alignment_score": None, + "reverse_target_suggestion": None, + } if _redis is None: try: _redis = aioredis.from_url(settings.redis_url, decode_responses=True) @@ -370,7 +808,7 @@ def _safe(x, default): try: await _redis.set( cache_key, - json.dumps(report, ensure_ascii=False), + json.dumps(cacheable_report, ensure_ascii=False), ex=_CACHE_TTL, ) print(f"[demographic] 캐시 저장: {cache_key} (TTL {_CACHE_TTL}s)") @@ -382,6 +820,8 @@ def _safe(x, default): except Exception: pass + # state 에는 alignment 포함된 full report 주입. + report = base_report analysis_results = dict(state.get("analysis_results", {}) or {}) analysis_results["demographic_report"] = report @@ -389,6 +829,9 @@ def _safe(x, default): _share_pct_main = round(float(core.share) * 100, 1) except Exception: _share_pct_main = 0 + _verdict_main = f"주 소비층 {core.age} {core.gender} ({_share_pct_main}%)" + if alignment_score is not None: + _verdict_main += f" · 타겟 정렬 {alignment_score:.0f}/100" demo_attr = build_attribution( agent_id="demographic_depth", display_name="인구 심층분석", @@ -399,7 +842,7 @@ def _safe(x, default): "kosis_regional_income", "elderly_ratio_region", ], - verdict=f"주 소비층 {core.age} {core.gender} ({_share_pct_main}%)", + verdict=_verdict_main, reasoning=str(analysis_out.narrative) if analysis_out and analysis_out.narrative else "소비자 심층 분석 데이터 기반", diff --git a/backend/src/agents/nodes/synthesis.py b/backend/src/agents/nodes/synthesis.py index d2850948..c20b9886 100644 --- a/backend/src/agents/nodes/synthesis.py +++ b/backend/src/agents/nodes/synthesis.py @@ -77,7 +77,8 @@ async def synthesis_node(state: AgentState) -> dict: _winner_for_cache = state.get("winner_district", target_district) _raw_td = state.get("target_districts") or [target_district] _td_key = ",".join(sorted(set(d for d in _raw_td if d))) - cache_key = f"v13:synthesis:{brand_name}:{_winner_for_cache}:{_td_key}:{business_type}:{monthly_rent_budget}:{store_area}:{state.get('population_weight', True)}" + # v14: 사용자 타겟 정렬도 + 역제안 → '리스크 및 대응' 섹션 액션 제안 반영. 이전 캐시 무효화. + cache_key = f"v14:synthesis:{brand_name}:{_winner_for_cache}:{_td_key}:{business_type}:{monthly_rent_budget}:{store_area}:{state.get('population_weight', True)}" _redis = None try: _redis = aioredis.from_url(settings.redis_url, decode_responses=True) @@ -293,6 +294,38 @@ async def synthesis_node(state: AgentState) -> dict: else: shap_block = "" + # 사용자 타겟 정렬도 + 역제안 — '리스크 및 대응' 섹션 액션 제안 근거. + # demographic_report.target_alignment / reverse_target_suggestion (사용자 입력별 fresh). + _demo = analysis_results.get("demographic_report") or {} + _ta_alerts = _demo.get("target_alignment") or [] + _ta_score = _demo.get("target_alignment_score") + _rev = _demo.get("reverse_target_suggestion") or {} + alignment_block = "" + if _ta_alerts or _rev: + _high = [a for a in _ta_alerts if isinstance(a, dict) and a.get("severity") == "high"] + _med = [a for a in _ta_alerts if isinstance(a, dict) and a.get("severity") == "medium"] + _alert_lines = "\n".join( + f" - [{a.get('severity', '?')}] {a.get('dimension', '?')}: {a.get('message', '')[:200]}" + for a in (_high + _med)[:5] + ) + _rev_line = "" + if _rev: + _rev_age = ", ".join(_rev.get("recommended_age_groups") or []) or "—" + _rev_g = _rev.get("recommended_gender") or "혼재" + _rev_h = ", ".join(_rev.get("recommended_hours") or []) or "—" + _rev_d = _rev.get("recommended_day_type") or "—" + _rev_p = _rev.get("recommended_price_range") or "—" + _rev_line = ( + f"\n역제안 (입지 고정 시 권장 타겟): 연령 {_rev_age} / 성별 {_rev_g} / " + f"시간대 {_rev_h} / 요일 {_rev_d} / 객단가 {_rev_p}.\n" + f"근거: {(_rev.get('rationale') or '')[:200]}" + ) + alignment_block = ( + f"\n[사용자 타겟 정렬 — 정렬도 {_ta_score}/100 · 미스매치 {len(_high)}건 high, {len(_med)}건 medium]\n" + f"{_alert_lines}" + f"{_rev_line}" + ) + # competitor_intel 요약 (경쟁/카니발/차별화) — legal_risks 와 독립적으로 병합 competitor_intel = state.get("competitor_intel_result", {}) or {} if competitor_intel and "error" not in competitor_intel: @@ -321,6 +354,7 @@ async def synthesis_node(state: AgentState) -> dict: + (f"{quarterly_block}\n" if quarterly_block else "") + (f"{shap_block}\n" if shap_block else "") + (f"{competitor_block}\n" if competitor_block else "") + + (f"{alignment_block}\n" if alignment_block else "") + f"법률(caution/danger {len(_active_legal_risks)}건):\n{legal_summary_for_llm}\n" f"{legal_override}" f"{demographic_context}\n" @@ -345,6 +379,12 @@ async def synthesis_node(state: AgentState) -> dict: " - 블록에 없는 항목(예: 식품위생법, 위생교육, 소방시설 의무, 근로계약서 등)을 임의로 추가·생성·언급하지 말 것.\n" " - 법률(caution/danger 0건) 인 경우 법률 항목 없이 운영 일반 리스크(경쟁·매출 변동·계절성 등)만 다룬다.\n" " - 각 항목은 위 블록 summary 를 근거로 1-2문장 + 사전 대응 단계.\n" + "12. [필수 — 사용자 타겟 정렬 반영]\n" + " - [사용자 타겟 정렬] 블록이 있으면 '리스크 및 대응' 섹션 마지막에 별도 항목으로\n" + " '타겟 정렬 점검' 추가. high/medium alert 의 message 를 근거로 액션 제안 작성.\n" + " - 역제안이 있으면 액션 제안에 '입지 유지 시 [권장 연령/성별/시간/요일/객단가] 로 타겟·\n" + " 운영전략 재정의 검토' 한 줄 포함. 사용자 자유 의사결정 보조용 — 강제하지 말 것.\n" + " - 정렬도 60+ 또는 alert 0건이면 본 항목 생략.\n" " - **법률 조항 번호 인용 금지** (예: '제12조의4', '제43조', '가맹사업법 제○조' 등 조문 ref 표기 절대 금지).\n" " · 사용자 요구: 상권 무관 조항 인용으로 혼란 발생 → 본 섹션엔 행동 권고만, 조항 인용은 별도 LegalDrawer 가 처리.\n" " · '제○조' / '제○조의○' 패턴 일체 출력 금지. 법률명만 (예: '가맹사업법') 언급 가능.\n" diff --git a/backend/src/schemas/demographic.py b/backend/src/schemas/demographic.py index 70ba9572..28db6742 100644 --- a/backend/src/schemas/demographic.py +++ b/backend/src/schemas/demographic.py @@ -1,5 +1,7 @@ """Pydantic schemas for demographic_depth agent output.""" +from typing import Literal + from pydantic import BaseModel, Field @@ -9,11 +11,48 @@ class CoreDemographic(BaseModel): share: float = Field(description="해당 세그먼트 매출 비중 (0-1)") +class TargetAlignmentAlert(BaseModel): + """사용자 입력 타겟 vs 동·업종 실측 매출 분포 미스매치 알림. + + 프론트 인구분석탭에서 '내 타겟이 이 입지의 실 소비층과 어긋남' 신호를 띄우는 데 사용. + """ + + dimension: Literal["age", "gender", "hours", "day", "price"] = Field(description="매칭 차원") + severity: Literal["high", "medium", "low"] = Field( + description="미스매치 강도. high=즉시 재검토, medium=주의, low=경미" + ) + user_input: str = Field(description="사용자가 입력한 타겟값 (UI 표기용)") + actual: str = Field(description="실측 값 요약 (예: '50대 35%·점심 피크')") + message: str = Field(description="한 줄 액션 메시지 (예: '20대 타겟인데 실 매출 50대 1위')") + + class AgeShare(BaseModel): age_group: str = Field(description="연령대 라벨 (10/20/30/40/50/60+)") share: float = Field(description="매출 비중 (0-1)") +class ReverseTargetSuggestion(BaseModel): + """alert high 발생 시 실측 기반의 권장 타겟 프로필(역제안). + + "이 입지에서 *어떤 타겟* 으로 운영하면 정렬도가 90+ 가 되는가" 를 사용자에게 + 보여주는 reverse-suggestion. 입지를 바꿀 수 없을 때 타겟·운영전략 재정의로 + 적합도를 끌어올릴 수 있는지 판단 보조. + """ + + recommended_age_groups: list[str] = Field( + default_factory=list, description="권장 타겟 연령대(매출 상위 1-2개). 예: ['20대','30대']" + ) + recommended_gender: str | None = Field(default=None, description="권장 타겟 성별. 'male'/'female'/None(혼재)") + recommended_hours: list[str] = Field( + default_factory=list, description="권장 영업시간 카테고리(피크 기반). 예: ['점심','저녁']" + ) + recommended_day_type: str | None = Field(default=None, description="권장 요일 타겟. 'weekday'/'weekend'/None") + recommended_price_range: str | None = Field( + default=None, description="권장 객단가 구간. 'lt5k'/'5to10k'/'10to15k'/'gt15k'/None" + ) + rationale: str = Field(description="실측 매출 근거 1-2문장") + + class DemographicAnalysis(BaseModel): """LLM이 structured output으로 생성하는 필드만. 정량 계산은 코드에서 별도.""" @@ -48,3 +87,10 @@ class DemographicReport(BaseModel): brand_target_match_score: float | None = None match_rationale: str | None = None narrative: str + # 사용자 입력 타겟(target_age_groups/target_gender/operating_hours/target_day_type/ + # target_price_range) 대비 실측 매출·소득 분포 미스매치 평가. 사용자 입력이 비면 빈 리스트. + target_alignment: list[TargetAlignmentAlert] = Field(default_factory=list) + # 0-100 점, 사용자 타겟 정렬도. 입력이 전혀 없으면 None. + target_alignment_score: float | None = None + # high severity alert 가 1개 이상 있을 때 채워지는 역제안. 정렬 양호 시 None. + reverse_target_suggestion: ReverseTargetSuggestion | None = None diff --git a/frontend/src/components/SimulationResult/dashboard/charts/FlowVsRevenueScatter.tsx b/frontend/src/components/SimulationResult/dashboard/charts/FlowVsRevenueScatter.tsx index 489a7620..c1f79003 100644 --- a/frontend/src/components/SimulationResult/dashboard/charts/FlowVsRevenueScatter.tsx +++ b/frontend/src/components/SimulationResult/dashboard/charts/FlowVsRevenueScatter.tsx @@ -111,11 +111,9 @@ export function FlowVsRevenueScatter({ rankings, winnerDistrict }: Props) { )} {points.map((p, i) => ( - + // winner 는 색상(var(--danger))으로만 강조 — 크기는 다른 점과 동일하게 통일. + // 이전엔 var(--primary)(파랑) 이라 회색 점들과 구분이 약했음. + ))} diff --git a/frontend/src/components/SimulationResult/dashboard/tabs/DemographicTab.tsx b/frontend/src/components/SimulationResult/dashboard/tabs/DemographicTab.tsx index a9953334..d57b0d3c 100644 --- a/frontend/src/components/SimulationResult/dashboard/tabs/DemographicTab.tsx +++ b/frontend/src/components/SimulationResult/dashboard/tabs/DemographicTab.tsx @@ -9,8 +9,8 @@ * 2) 하단: 인구 심층 리포트 (MetricBox 4 + narrative + match_rationale) */ -import { Users } from 'lucide-react'; -import type { SimulationOutput } from '../../../../types'; +import { Users, AlertTriangle, Lightbulb } from 'lucide-react'; +import type { SimulationOutput, TargetAlignmentAlert } from '../../../../types'; import { MetricBox } from '../shared/MetricBox'; import { INCOME_MAP, TREND_MAP, safeMap, mapGender } from '../utils/mappings'; import { formatPeakHours } from '../utils/formatters'; @@ -18,6 +18,53 @@ import { CoreDemographicDonut } from '../charts/CoreDemographicDonut'; import { WeekdayWeekendBar } from '../charts/WeekdayWeekendBar'; import { StackedAgeBar } from '../charts/StackedAgeBar'; +// 정렬도 점수 → 뱃지 색상 토큰. 60 미만은 빨강(즉시 재검토), 60-79 노랑(주의), 80+ 파랑(양호). +function alignmentColorClasses(score: number): string { + if (score < 60) return 'bg-danger/10 border-danger/30 text-danger'; + if (score < 80) return 'bg-warning/10 border-warning/30 text-warning'; + return 'bg-primary/10 border-primary/20 text-primary'; +} + +// severity → alert row 색상 토큰. +function severityClasses(severity: TargetAlignmentAlert['severity']): { + border: string; + bg: string; + text: string; + label: string; +} { + if (severity === 'high') { + return { + border: 'border-danger/40', + bg: 'bg-danger/5', + text: 'text-danger', + label: '높음', + }; + } + if (severity === 'medium') { + return { + border: 'border-warning/40', + bg: 'bg-warning/5', + text: 'text-warning', + label: '주의', + }; + } + return { + border: 'border-border', + bg: 'bg-card', + text: 'text-muted-foreground', + label: '경미', + }; +} + +// dimension 영문 → 한국어 라벨. +const DIMENSION_LABEL: Record = { + age: '연령대', + gender: '성별', + hours: '시간대', + day: '요일', + price: '객단가', +}; + interface Props { simResult: SimulationOutput; } @@ -33,6 +80,13 @@ export function DemographicTab({ simResult }: Props) { const match = demo?.brand_target_match_score; const narrative = demo?.narrative; const rationale = demo?.match_rationale; + // 사용자 입력 타겟 vs 실측 정렬도. brand_target_match_score 와 별개의 점수. + // brand_target_match_score: 브랜드의 일반적 타겟 vs 실측 + // alignmentScore: 사용자가 시뮬 입력폼에 직접 넣은 타겟 vs 실측 + const alignmentScore = demo?.target_alignment_score ?? null; + const alignmentAlerts = demo?.target_alignment ?? []; + // 역제안 — high severity alert 발생 시 백엔드가 실측 기반으로 채워줌. 없으면 null. + const reverseSuggestion = demo?.reverse_target_suggestion ?? null; const hasAnyComposition = core || @@ -96,8 +150,12 @@ export function DemographicTab({ simResult }: Props) { {match != null && ( -
+
브랜드 적합도 {Math.round(match)} + LLM 종합
)}
@@ -131,6 +189,174 @@ export function DemographicTab({ simResult }: Props) { )}
)} + + {/* 내 타겟 정렬 카드 — 브랜드 적합도와 분리. + 브랜드 적합도(LLM 종합) ≠ 내 타겟 정렬(룰엔진 5차원 평균)이라 같은 0-100 척도여도 + 직접 비교 부적절. 보완 관계로 별도 카드 분리. 사용자 입력이 비어있어 점수 None 이면 + 카드 자체를 안 그림. */} + {alignmentScore != null && ( +
+
+

+ 내 타겟 정렬도 + + target_alignment + +

+
+ {Math.round(alignmentScore)} + 5차원 평균 +
+
+ + {/* 두 점수 의미 차이 설명 — 사용자가 같은 0-100 으로 비교하지 않도록 */} +

+ 위 브랜드 적합도 는 *브랜드의 일반적 + 타겟* 이 이 입지의 실측 매출 분포와 맞는지 LLM 이 종합 판단한 점수이고, 여기 + 내 타겟 정렬도 는 *시뮬 입력 폼에 직접 + 넣은 타겟(연령·성별·시간·요일·객단가)* 5차원을 룰엔진으로 평가한 점수입니다. 같은 0-100 + 척도지만 측정 방식이 달라 직접 비교는 부적절 — 두 관점을 보완적으로 함께 보세요. +

+ + {alignmentAlerts.length > 0 && ( +
+
+ +

+ 입력 타겟과 실측 불일치 {alignmentAlerts.length}건 +

+
+
    + {alignmentAlerts.map((alert, i) => { + const cls = severityClasses(alert.severity); + return ( +
  • + + {cls.label} + +
    +
    + + {DIMENSION_LABEL[alert.dimension]} + + + 입력: {alert.user_input} + + · + + 실측: {alert.actual} + +
    +

    + {alert.message} +

    +
    +
  • + ); + })} +
+
+ )} + + {alignmentAlerts.length === 0 && ( +
+

+ 입력 타겟이 실측 매출 분포와 잘 맞습니다. 미스매치 차원 없음. +

+
+ )} + + {reverseSuggestion && ( +
+
+ +

+ 타겟 재정의 제안 +

+ + reverse_target_suggestion + +
+
+

+ 입지를 그대로 두고{' '} + 타겟·운영전략을 실측 분포에 + 맞춰 재정의할 때 권장: +

+
+ {reverseSuggestion.recommended_age_groups.length > 0 && ( +
+
+ 연령대 +
+
+ {reverseSuggestion.recommended_age_groups.join(', ')} +
+
+ )} + {reverseSuggestion.recommended_gender && ( +
+
+ 성별 +
+
+ {reverseSuggestion.recommended_gender === 'male' + ? '남성' + : reverseSuggestion.recommended_gender === 'female' + ? '여성' + : '혼재'} +
+
+ )} + {reverseSuggestion.recommended_hours.length > 0 && ( +
+
+ 시간대 +
+
+ {reverseSuggestion.recommended_hours.join(', ')} +
+
+ )} + {reverseSuggestion.recommended_day_type && ( +
+
+ 요일 +
+
+ {reverseSuggestion.recommended_day_type === 'weekday' ? '평일' : '주말'} +
+
+ )} + {reverseSuggestion.recommended_price_range && ( +
+
+ 객단가 +
+
+ {reverseSuggestion.recommended_price_range} +
+
+ )} +
+

+ {reverseSuggestion.rationale} +

+
+
+ )} +
+ )}
); } diff --git a/frontend/src/components/SimulationResult/sections/MarketMap.tsx b/frontend/src/components/SimulationResult/sections/MarketMap.tsx index 9965c47c..14fe02be 100644 --- a/frontend/src/components/SimulationResult/sections/MarketMap.tsx +++ b/frontend/src/components/SimulationResult/sections/MarketMap.tsx @@ -523,12 +523,18 @@ export function MarketMap({ overlayLayersRef.current.push(overlay); // top5 별도 번호 라벨 — 빨간 삼각형 위에 약간 떠있게 표시 (매장은 빨간 삼각형으로 그대로). + // 노란 뱃지 자체도 클릭 가능 — 같은 경쟁점 정보 popup 열림. + // 회귀 fix(2026-05-06): 이전 'pointer-events:none' 으로 클릭 차단되던 문제 제거. if (isTop5 && !isSelfBrand) { const badge = document.createElement('div'); badge.style.cssText = - 'position:relative;width:20px;height:20px;display:flex;align-items:center;justify-content:center;background:#facc15;border:2px solid #ffffff;border-radius:9999px;box-shadow:0 0 8px rgba(250,204,21,0.8);font-size:11px;font-weight:900;color:#1c1917;cursor:pointer;pointer-events:none;'; + 'position:relative;width:20px;height:20px;display:flex;align-items:center;justify-content:center;background:#facc15;border:2px solid #ffffff;border-radius:9999px;box-shadow:0 0 8px rgba(250,204,21,0.8);font-size:11px;font-weight:900;color:#1c1917;cursor:pointer;'; badge.innerHTML = String(top5Rank); badge.title = `주요 경쟁점 ${top5Rank}위 — ${c.place_name}`; + badge.addEventListener('click', (ev) => { + ev.stopPropagation(); + openPopup(pos, buildCompetitorInfoHtml(c, radius, withinCenterLat, withinCenterLng)); + }); const badgeOverlay = new maps.CustomOverlay({ position: pos, content: badge, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 61616da7..1c907e88 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -283,6 +283,25 @@ export interface CompetitorIntel { narrative?: string; } +/** 사용자 입력 타겟 vs 동·업종 실측 매출 분포 미스매치 알림. */ +export interface TargetAlignmentAlert { + dimension: 'age' | 'gender' | 'hours' | 'day' | 'price'; + severity: 'high' | 'medium' | 'low'; + user_input: string; + actual: string; + message: string; +} + +/** alert high 발생 시 실측 기반 권장 타겟 프로필(역제안). */ +export interface ReverseTargetSuggestion { + recommended_age_groups: string[]; + recommended_gender: string | null; + recommended_hours: string[]; + recommended_day_type: string | null; + recommended_price_range: string | null; + rationale: string; +} + /** 인구통계 심층 분석 (demographic_depth 에이전트) */ export interface DemographicReport { core_demographic: { age: string; gender: string; share: number }; @@ -298,6 +317,12 @@ export interface DemographicReport { narrative: string; // Track B #106 — 백엔드 peak_hour_matrix [7][24] 제공 시 자동 활성화 peak_hour_matrix?: number[][] | null; + // 사용자 입력 타겟(연령·성별·시간·요일·객단가) 정렬도 0-100. 입력 없으면 null. + target_alignment_score?: number | null; + // 정렬 미스매치 alert 목록. 입력 없거나 모든 차원 매치면 빈 배열. + target_alignment?: TargetAlignmentAlert[]; + // high severity alert 발생 시 실측 기반 권장 타겟 프로필(역제안). 정렬 양호 시 null. + reverse_target_suggestion?: ReverseTargetSuggestion | null; } /**