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 에이전트 병렬 실행 시작 ---") ( 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/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/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/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/main.py b/backend/src/main.py index 2da51076..b92b97d1 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": "카페", @@ -248,24 +313,216 @@ def _pipeline_key(input_data: Any) -> str: # config/business_type_mapping.kakao_keyword_of() 사용 — 단일 source of truth. +def _resolve_top_spot_coords(result: dict, max_n: int = 4) -> list[tuple[float, float]]: + """vacancy_spots 중 top N 좌표 list 반환 (frontend buildBestVacancies 와 동일 로직). + + 선정 규칙: + 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 [] + 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)) + + 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)] + 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), + ) + ) + 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) + ] + 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( + 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.sync_engine 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_coords: list[tuple[float, float]] | None = None, + spot_radius_m: int = 1500, + spot_limit: int = 800, ) -> 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_coords (4 spot list) → 각 spot 별 1.5km 검색 후 합집합 + dedup + (사용자 요청: "공실 spot 1~4 모두 기준으로 경쟁점 표시") + · spot_lat/lon (단일 spot) → 단일 검색 (구버전 호환) + · 둘 다 없으면 → fallback: winner+top3 4동 centroid 1.5km 검색 + + 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 - districts = list({winner} | set(top3 or [])) - print(f"[all_competitors] 수집 시작 — business_type={business_type} keyword={keyword} districts={districts}") + + # 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}) " + 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 → 최종 @@ -930,7 +1187,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 +1204,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", @@ -966,11 +1230,14 @@ async def analyze_location(input_data: SimulationInput, response: Response): # 추천 동 전체(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_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 + 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 ) - 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 +1254,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 +1265,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", @@ -1027,15 +1300,18 @@ async def analyze_llm(input_data: SimulationInput): # 경쟁업체 좌표 수집 (지도 멀티핀용) — winner 기준 winner = full.get("winner_district") or input_data.target_district top3 = full.get("top_3_candidates") or [] + _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 + winner, top3, input_data.business_type, spot_coords=_spot_coords ) except Exception as e: 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 +1335,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 +1349,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", @@ -1120,15 +1402,18 @@ 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_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 + winner, top3, input_data.business_type, spot_coords=_spot_coords ) except Exception as ce: 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 +1422,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 +1464,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 +1479,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 +1523,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 +1996,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 +2009,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 +2071,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 +2084,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 +2192,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,13 +2228,16 @@ 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"] = [] try: + _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 + winner, top3, input_data.business_type, spot_coords=_spot_coords ) except Exception as ce: print(f"[SIMULATE] all_competitor_locations 수집 실패 (무시): {ce}") 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/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가 자동 주입) // ───────────────────────────────────────────────────────── 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/dashboard/tabs/MarketTab.tsx b/frontend/src/components/SimulationResult/dashboard/tabs/MarketTab.tsx index 47dcf313..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, @@ -19,6 +20,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 +82,110 @@ 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 _spot1Info: { lat: number; lng: number; dongName: string } | 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, + 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)) + .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, + 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; + + // 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) + .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; + }; + 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); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [simResult, _spot1?.lat, _spot1?.lng]); return (
@@ -117,7 +218,7 @@ export function MarketTab({ simResult }: Props) {
{/* 기존 MapSection 재활용 (Kakao SDK) */}
- +
@@ -129,6 +230,7 @@ export function MarketTab({ simResult }: Props) { rentIndex={rentIndex} topCompetitors={topCompetitors} simResult={simResult} + analysisDong={analysisDong} /> @@ -403,6 +505,7 @@ interface SidebarProps { rentIndex: number | null; topCompetitors: MarketCompetitorSample[]; simResult: SimulationOutput; + analysisDong: string | null; } function MarketAnalysisSidebar({ @@ -412,6 +515,7 @@ function MarketAnalysisSidebar({ rentIndex, topCompetitors, simResult, + analysisDong, }: SidebarProps) { const metrics: Array<{ label: string; value: string }> = [ { @@ -511,21 +615,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 6f2822bd..6d42c59b 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'; @@ -8,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 }; @@ -32,11 +40,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 +63,41 @@ 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자리) 동일하면 동일 매장으로 판단. + // 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) { + 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 >= 1000) 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'), @@ -120,46 +164,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, ); @@ -169,7 +231,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]); @@ -181,6 +243,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); @@ -197,11 +277,13 @@ export function MapSection({ simResult }: 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 = @@ -239,6 +321,38 @@ export function MapSection({ simResult }: Props) {
+ {/* winner != spot 1위 동 케이스 안내 — 큰 배너로 강조. + bestVacancy.dongName !== winner 일 때만 표시. */} + {bestVacancy && bestVacancy.dongName !== district && district !== '—' && ( +
+
+ +
+
+ 추천 입지 안내 — 매물 자동 보정 +
+
+ 종합 점수 1순위 추천 동{' '} + + {district} + + 에 실제 임대 매물이 없어, 인접 + 후보 동{' '} + + {bestVacancy.dongName} + + 의 매물 중 최적 위치를 공실 spot 1위로 자동 추천합니다. +
+
+ ▸ 분석 결과(반경 500m 동일업종, 평균 거리, 경쟁 강도, 임대료 인덱스), 주요 경쟁점, + 동 한눈에 — 모두{' '} + {bestVacancy.dongName} 기준으로 + 도출됩니다. +
+
+
+
+ )}
{/* Layer 6 — 좌하단 범례 패널 */} @@ -269,6 +384,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; } // 핀/반경원 좌표 우선순위: @@ -410,11 +433,45 @@ 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( 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 안에 자사 매장이 들어와 있으면 별표 마커로 분기. @@ -424,9 +481,15 @@ 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)}`); + const isTop5 = top5Rank != null && top5Rank >= 1 && top5Rank <= 5; const dot = document.createElement('div'); if (isSelfBrand) { @@ -436,22 +499,17 @@ export function MarketMap({ dot.innerHTML = '★'; dot.title = `${c.brand_name || '자사매장'} · ${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) => { 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({ @@ -463,6 +521,30 @@ export function MarketMap({ }); overlay.setMap(mapInstance); 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;'; + 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, + xAnchor: 0.5, + yAnchor: 1.6, // 빨간 삼각형 위쪽으로 띄움 — 삼각형이 그대로 보임 + zIndex: 6, + }); + badgeOverlay.setMap(mapInstance); + overlayLayersRef.current.push(badgeOverlay); + } }); // Layer 3 — 자사 매장 마커 (로고 아이콘 별표 only — 영업구역 점선 원은 사용자 요구로 제거) @@ -477,7 +559,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 +570,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 +634,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 +654,7 @@ export function MarketMap({ sameBrandLocations, territoryRadiusM, userBrand, + topCompetitors, ]); if (error) { 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; } /**