From 66b874e7601869381ba4dfa14f53bf1bba698d6e Mon Sep 17 00:00:00 2001 From: bat1120 Date: Wed, 6 May 2026 12:03:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(corp):=20=EC=82=AC=EC=97=85=EC=9E=90?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EA=B8=B0=EB=B0=98=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EC=97=85=EC=A2=85=20dropdown=20=EC=9E=90=EB=8F=99=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/src/services/corp_brand_resolver.py: users.company_name → ftc_brand_franchise.corpNm 매칭, get_corp_industries / resolve_brand_for_industry - backend/src/main.py: GET /corp/operated-industries (JWT 기반 자동 추출), _validate_and_resolve_brand 헬퍼 + 7 endpoint Depends(get_optional_user) 통합 - backend/src/schemas/simulation_input.py: biz_number optional 필드 - frontend/src/api/client.ts: getOperatedIndustries() + OperatedIndustriesResponse 타입 - frontend/src/App.tsx: mount 시 fetch + 운영 외 frontend 라벨 disable + line-through + click toast 다업종 corp ((주)더본코리아 8업종 27 brand 등) 시 운영 외 업종 dropdown 차단. 비회원/CORP_NOT_IN_FTC: industries=null 반환 → 모든 업종 허용 (graceful degrade). Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/main.py | 187 ++++++++++++++++++-- backend/src/schemas/simulation_input.py | 7 + backend/src/services/corp_brand_resolver.py | 150 ++++++++++++++++ frontend/src/App.tsx | 83 +++++++-- frontend/src/api/client.ts | 24 +++ 5 files changed, 422 insertions(+), 29 deletions(-) create mode 100644 backend/src/services/corp_brand_resolver.py diff --git a/backend/src/main.py b/backend/src/main.py index 2da51076..e4a8a2f4 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -47,7 +47,7 @@ # LangSmith 트레이싱: langchain import 전에 os.environ 주입 필수 # (langchain SDK는 import 시점에 LANGCHAIN_TRACING_V2를 읽으므로 순서가 중요) from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException, Request, Response +from fastapi import Depends, FastAPI, HTTPException, Request, Response from fastapi.concurrency import run_in_threadpool from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -77,6 +77,7 @@ from src.schemas.simulation_input import SimulationInput from src.services.auth import AuthService from src.services.biz_mapper import BizMapper +from src.services.jwt_auth import UserContext, get_optional_user from models.explainability.shap_analysis import explain_tcn_prediction from models.explainability.simulation import ( @@ -225,6 +226,70 @@ def _pipeline_key(input_data: Any) -> str: return f"{input_data.target_district}:{input_data.business_type}:{input_data.brand_name}:{rent}:{area}:{radius}:{pop_w}" +def _resolve_user_biz_number(user: UserContext | None) -> str | None: + """JWT user → users.biz_number 조회. master 는 본인, manager 는 owner 의 biz_number.""" + if user is None: + return None + target_id = user.owner_id if user.role == "manager" else user.user_id + if not target_id: + return None + try: + import sqlalchemy as sa + + engine = sa.create_engine(settings.postgres_url) + with engine.connect() as conn: + row = conn.execute( + sa.text("SELECT biz_number FROM users WHERE id = :id"), + {"id": target_id}, + ).first() + return row._mapping["biz_number"] if row else None + except Exception as ex: + logger.warning(f"[brand_resolver] biz_number 조회 실패: {ex}") + return None + + +def _validate_and_resolve_brand( + input_data: SimulationInput, + current_user: UserContext | None = None, +) -> None: + """biz_number 입력 시 corp 검증 + 다업종 corp 의 brand auto-resolve. + + biz_number 우선순위: + 1. ``input_data.biz_number`` (frontend 명시 입력) + 2. JWT ``current_user`` 토큰에서 자동 추출 (master.user_id 또는 manager.owner_id) + 3. 없으면 검증 skip (개인사업자 / 비회원 호환) + + 동작: + 1. business_type 이 사용자 corp 의 운영 업종인지 검증. + 2. 운영 외 업종 → HTTPException(400) + 운영 가능 업종 list 응답. + 3. 운영 내 업종 + corp 의 해당 업종 brand 가 다른 brand 면 brand_name override. + """ + biz_number = input_data.biz_number or _resolve_user_biz_number(current_user) + if not biz_number: + return + + from src.services.corp_brand_resolver import resolve_brand_for_industry + + result = resolve_brand_for_industry(biz_number, input_data.business_type) + + if result.get("error") == "INDUSTRY_NOT_OPERATED": + raise HTTPException(status_code=400, detail=result) + + if result.get("error") in {"USER_NOT_FOUND", "CORP_NOT_IN_FTC", "INVALID_COMPANY_NAME"}: + # 비회원 / FTC 미등록 → 검증 skip, 사용자 brand_name 그대로 + logger.warning(f"[brand_resolver] {result['error']} biz={biz_number} — fallback to input.brand_name") + return + + # 성공: brand_name override (사용자가 다른 brand 입력했어도 corp 정합 brand 로 교체) + resolved_brand = result["brand_name"] + if input_data.brand_name != resolved_brand: + logger.info( + f"[brand_resolver] auto-resolve: input.brand_name='{input_data.brand_name}' → '{resolved_brand}' " + f"(corp={result['company_name']}, industry={input_data.business_type})" + ) + input_data.brand_name = resolved_brand + + _BIZ_TYPE_NORMALIZE: dict[str, str] = { "cafe": "카페", "coffee": "카페", @@ -930,7 +995,11 @@ async def get_status(job_id: str): @app.post("/analyze") -async def analyze_location(input_data: SimulationInput, response: Response): +async def analyze_location( + input_data: SimulationInput, + response: Response, + current_user: UserContext | None = Depends(get_optional_user), +): """[DEPRECATED] 풀파이프 상권 분석 — 전환 기간 동안만 유지. IM3-259로 endpoint를 분리(/predict + /analyze/llm)했으므로 신규 호출은 @@ -943,6 +1012,9 @@ async def analyze_location(input_data: SimulationInput, response: Response): response.headers["Deprecation"] = "true" response.headers["Link"] = '; rel="successor-version", ; rel="successor-version"' + # corp 다업종 brand auto-resolve + 운영 외 업종 차단 (biz_number 입력 시만) + _validate_and_resolve_brand(input_data, current_user) + if input_data.target_district not in MAPO_DISTRICTS: return { "status": "error", @@ -970,7 +1042,9 @@ async def analyze_location(input_data: SimulationInput, response: Response): result["all_competitor_locations"] = await _collect_all_competitor_locations( winner, top3, input_data.business_type ) - result["same_brand_locations"] = await _collect_same_brand_locations(winner, top3, input_data.brand_name, input_data.business_type) + result["same_brand_locations"] = await _collect_same_brand_locations( + winner, top3, input_data.brand_name, input_data.business_type + ) return {"status": "success", "data": result} except Exception as e: print(f"!!! [API ERROR] !!! {str(e)}") @@ -987,7 +1061,10 @@ async def analyze_location(input_data: SimulationInput, response: Response): @app.post("/analyze/llm") -async def analyze_llm(input_data: SimulationInput): +async def analyze_llm( + input_data: SimulationInput, + current_user: UserContext | None = Depends(get_optional_user), +): """AI 분석 전용 endpoint — slow_graph 실행 (~80-140초). /predict와 독립 병렬 호출 가능. winner는 ranking 단계에서 자체 결정. @@ -995,6 +1072,9 @@ async def analyze_llm(input_data: SimulationInput): from src.config.constants import MAPO_DISTRICTS from src.schemas.simulation_output import AnalysisOutput + # corp 다업종 brand auto-resolve + 운영 외 업종 차단 + _validate_and_resolve_brand(input_data, current_user) + if input_data.target_district not in MAPO_DISTRICTS: return { "status": "error", @@ -1035,7 +1115,9 @@ async def analyze_llm(input_data: SimulationInput): print(f"[ANALYZE/LLM] all_competitor_locations 수집 실패 (무시): {e}") full["all_competitor_locations"] = [] try: - full["same_brand_locations"] = await _collect_same_brand_locations(winner, top3, input_data.brand_name, input_data.business_type) + full["same_brand_locations"] = await _collect_same_brand_locations( + winner, top3, input_data.brand_name, input_data.business_type + ) except Exception as e: print(f"[ANALYZE/LLM] same_brand_locations 수집 실패 (무시): {e}") full["same_brand_locations"] = [] @@ -1059,7 +1141,10 @@ async def analyze_llm(input_data: SimulationInput): @app.post("/analyze/llm/async") -async def analyze_llm_async(input_data: SimulationInput) -> dict[str, Any]: +async def analyze_llm_async( + input_data: SimulationInput, + current_user: UserContext | None = Depends(get_optional_user), +) -> dict[str, Any]: """AI 분석 비동기 시작 — 즉시 job_id 반환. LangGraph 노드별 진행률 추적.""" from src.config.constants import MAPO_DISTRICTS from src.schemas.simulation_output import AnalysisOutput @@ -1070,6 +1155,9 @@ async def analyze_llm_async(input_data: SimulationInput) -> dict[str, Any]: set_progress, ) + # corp 다업종 brand auto-resolve + 운영 외 업종 차단 + _validate_and_resolve_brand(input_data, current_user) + if input_data.target_district not in MAPO_DISTRICTS: return { "status": "error", @@ -1128,7 +1216,9 @@ async def _run() -> None: logger.warning(f"[/analyze/llm/async] all_competitor_locations 실패 (무시): {ce}") full["all_competitor_locations"] = [] try: - full["same_brand_locations"] = await _collect_same_brand_locations(winner, top3, input_data.brand_name, input_data.business_type) + full["same_brand_locations"] = await _collect_same_brand_locations( + winner, top3, input_data.brand_name, input_data.business_type + ) except Exception as ce: logger.warning(f"[/analyze/llm/async] same_brand_locations 실패 (무시): {ce}") full["same_brand_locations"] = [] @@ -1137,6 +1227,13 @@ async def _run() -> None: payload = {k: v for k, v in full.items() if k in analysis_keys} payload["request_id"] = request_id payload["target_district"] = full.get("target_district") or input_data.target_district + # DEBUG: payload 직전 same_brand_locations 검증 (frontend 측 누락 의심 시) + logger.info( + f"[/analyze/llm/async] payload check job={job_id[:8]} " + f"same_brand={len(payload.get('same_brand_locations', []) or [])} " + f"all_competitor={len(payload.get('all_competitor_locations', []) or [])} " + f"keys_count={len(payload)}" + ) set_done(job_id, _safe_json(payload)) logger.info(f"[/analyze/llm/async] 완료 job={job_id[:8]}") except Exception as e: @@ -1172,7 +1269,10 @@ async def analyze_llm_job_status(job_id: str) -> dict[str, Any]: @app.post("/analyze/quick") -async def analyze_quick(input_data: SimulationInput): +async def analyze_quick( + input_data: SimulationInput, + current_user: UserContext | None = Depends(get_optional_user), +): """ LLM 없는 경량 랭킹 엔드포인트 (district_ranking 에이전트만 실행). @@ -1184,6 +1284,9 @@ async def analyze_quick(input_data: SimulationInput): from src.agents.nodes.district_ranking import district_ranking_node from src.agents.nodes.market_analyst import db_client + # corp 다업종 brand auto-resolve + 운영 외 업종 차단 + _validate_and_resolve_brand(input_data, current_user) + normalized_biz = _BIZ_TYPE_NORMALIZE.get(input_data.business_type.lower(), input_data.business_type) print(f"--- [API] /analyze/quick 요청: {input_data.target_district} / {normalized_biz} ---") @@ -1225,6 +1328,45 @@ class BizLookupRequest(BaseModel): company_name: str = "" +@app.get("/corp/operated-industries") +async def get_operated_industries( + biz_number: str | None = None, + current_user: UserContext | None = Depends(get_optional_user), +) -> dict: + """사용자 corp 의 운영 업종/브랜드 list 반환. + + Frontend 시뮬 입력 폼이 mount 시 호출 — dropdown 에서 운영 외 업종 disable 용. + + biz_number 우선순위: + 1. query param ``biz_number`` (frontend 명시) + 2. JWT 토큰의 user.user_id → users.biz_number 자동 추출 + + Returns: + 성공: ``{"company_name": str, "industries": [str, ...], "brands": [{name, industry, stores}, ...]}`` + 실패 (USER_NOT_FOUND/CORP_NOT_IN_FTC): ``{"industries": null, "error": ..., "company_name": ...}`` + 비회원 (biz_number 미입력 + 토큰 없음): ``{"industries": null}`` — 모든 업종 허용 + """ + from src.services.corp_brand_resolver import get_corp_industries + + biz = biz_number or _resolve_user_biz_number(current_user) + if not biz: + return {"industries": None, "company_name": None, "brands": []} + + portfolio = get_corp_industries(biz) + if "error" in portfolio: + return { + "industries": None, + "error": portfolio["error"], + "company_name": portfolio.get("company_name"), + "message": portfolio.get("message"), + } + return { + "company_name": portfolio["company_name"], + "industries": portfolio["industries"], + "brands": portfolio["brands"], + } + + @app.post("/biz/lookup") async def biz_lookup(req: BizLookupRequest): """사업자등록번호 + 기업명으로 프랜차이즈 브랜드 매핑. @@ -1659,7 +1801,10 @@ def _mock_simulation_response(target_district: str, request_id: str) -> dict: @app.post("/predict") -async def predict_districts(input_data: SimulationInput): +async def predict_districts( + input_data: SimulationInput, + current_user: UserContext | None = Depends(get_optional_user), +): """ 선택 동 1~4개 ML 예측 전용 엔드포인트 (LangGraph 미사용) @@ -1669,6 +1814,9 @@ async def predict_districts(input_data: SimulationInput): """ from src.config.constants import MAPO_DISTRICTS + # corp 다업종 brand auto-resolve + 운영 외 업종 차단 + _validate_and_resolve_brand(input_data, current_user) + target_districts = getattr(input_data, "target_districts", None) or [input_data.target_district] target_districts = [d for d in target_districts if d in MAPO_DISTRICTS][:4] @@ -1728,7 +1876,10 @@ async def predict_districts(input_data: SimulationInput): # 단계: 동별 _predict_single_district 가 끝날 때마다 progress = done/total. # --------------------------------------------------------------------------- @app.post("/predict/async") -async def predict_districts_async(input_data: SimulationInput) -> dict[str, Any]: +async def predict_districts_async( + input_data: SimulationInput, + current_user: UserContext | None = Depends(get_optional_user), +) -> dict[str, Any]: """ML 예측 비동기 시작 — 즉시 job_id 반환. 진행률은 status endpoint 폴링.""" from src.config.constants import MAPO_DISTRICTS from src.services.job_progress_store import ( @@ -1738,6 +1889,9 @@ async def predict_districts_async(input_data: SimulationInput) -> dict[str, Any] set_progress, ) + # corp 다업종 brand auto-resolve + 운영 외 업종 차단 + _validate_and_resolve_brand(input_data, current_user) + target_districts = getattr(input_data, "target_districts", None) or [input_data.target_district] target_districts = [d for d in target_districts if d in MAPO_DISTRICTS][:4] if not target_districts: @@ -1843,13 +1997,20 @@ async def predict_job_status(job_id: str) -> dict[str, Any]: @app.post("/simulate", deprecated=True) -async def run_simulation(input_data: SimulationInput, response: Response): +async def run_simulation( + input_data: SimulationInput, + response: Response, + current_user: UserContext | None = Depends(get_optional_user), +): """기본 시뮬레이션 엔드포인트""" response.headers["Deprecation"] = "true" response.headers["Link"] = '; rel="successor-version", ; rel="successor-version"' from src.config.constants import MAPO_DISTRICTS + # corp 다업종 brand auto-resolve + 운영 외 업종 차단 + _validate_and_resolve_brand(input_data, current_user) + if input_data.target_district not in MAPO_DISTRICTS: return { "status": "error", @@ -1872,7 +2033,9 @@ async def run_simulation(input_data: SimulationInput, response: Response): winner = result.get("winner_district") or input_data.target_district top3 = result.get("top_3_candidates") or [] try: - result["same_brand_locations"] = await _collect_same_brand_locations(winner, top3, input_data.brand_name, input_data.business_type) + result["same_brand_locations"] = await _collect_same_brand_locations( + winner, top3, input_data.brand_name, input_data.business_type + ) except Exception as ce: logger.warning(f"[/simulate] same_brand_locations 실패 (무시): {ce}") result["same_brand_locations"] = [] diff --git a/backend/src/schemas/simulation_input.py b/backend/src/schemas/simulation_input.py index 897ff42f..3fe26c0c 100644 --- a/backend/src/schemas/simulation_input.py +++ b/backend/src/schemas/simulation_input.py @@ -125,6 +125,13 @@ class SimulationInput(BaseModel): target_day_type: str | None = Field(default=None, description="타겟 요일: 'weekday' | 'weekend' | None(전체)") target_monthly_sales: int | None = Field(default=None, description="예상 월매출 (원). None=비율만 계산, 금액 제외") + # [corp_brand_resolver] biz_number 검증 트리거. + # frontend 가 보내거나 main.py 에서 JWT 토큰의 user.user_id → users.biz_number 자동 추출. + # corp 검증: 해당 biz_number 가 운영하는 brand+업종 list 매핑. + biz_number: str | None = Field( + default=None, description="사업자등록번호 (corp 다업종 검증 트리거 — 미입력 시 검증 skip)" + ) + @field_validator("business_type") @classmethod def _warn_unknown_business_type(cls, v: str) -> str: diff --git a/backend/src/services/corp_brand_resolver.py b/backend/src/services/corp_brand_resolver.py new file mode 100644 index 00000000..07373f9a --- /dev/null +++ b/backend/src/services/corp_brand_resolver.py @@ -0,0 +1,150 @@ +"""사업자번호 + 업종 → 같은 corp 의 해당 업종 자동 brand 매핑. + +다업종 법인 (예: (주)더본코리아 = 빽다방·홍콩반점·빽보이피자·새마을식당...) 의 경우 +회원가입 시 ``biz_brand_mapping`` 에 top frcsCnt brand 1개만 저장됨. +시뮬레이션 시 사용자가 다른 업종 (예: 중식) 선택하면 같은 corp 의 해당 업종 +가장 큰 brand (홍콩반점0410) 로 자동 resolve. + +운영 외 업종 선택 시 ``INDUSTRY_NOT_OPERATED`` 에러 + 운영 가능 업종 list 반환. + +설계: +- ``users.company_name`` (회원가입 시 기록) 기준 ``ftc_brand_franchise.corpNm`` 매칭 +- corpNm 표기 변형 흡수 — ILIKE + corp 핵심어 추출 (괄호/특수문자 제거) +- 매칭 brand 중 ``frcsCnt`` 큰 것 1개 선택 +- 운영 외 업종 → 거부 (사용자에게 운영 업종 list 안내) + +사용처: ``main.py`` 시뮬 endpoint 호출 직후, 시뮬 input.brand_name override. +""" + +from __future__ import annotations + +import logging +import re + +import sqlalchemy as sa + +from src.config.settings import settings + +logger = logging.getLogger(__name__) + + +_engine: sa.Engine | None = None + + +def _get_engine() -> sa.Engine: + global _engine + if _engine is None: + _engine = sa.create_engine(settings.postgres_url) + return _engine + + +# corpNm 핵심어 추출용 — '(주)', '㈜', '주식회사' 등 법인 prefix/suffix 제거 +_CORP_NOISE_RE = re.compile(r"\(주\)|㈜|주식회사|\([^)]*\)|\s+") + + +def _normalize_corp(name: str) -> str: + """corpNm 정규화 — 법인 표기 noise 제거 후 핵심어 추출.""" + if not name: + return "" + return _CORP_NOISE_RE.sub("", name).strip() + + +def get_corp_industries(biz_number: str) -> dict: + """사업자번호 → corp 의 운영 brand+업종 list. + + Args: + biz_number: 사업자등록번호 (하이픈 제거). + + Returns: + ``{"company_name": ..., "brands": [...], "industries": [...]}`` 또는 + ``{"error": "USER_NOT_FOUND" | "CORP_NOT_IN_FTC", ...}``. + """ + engine = _get_engine() + with engine.connect() as c: + user = c.execute( + sa.text("SELECT company_name FROM users WHERE biz_number = :biz"), + {"biz": biz_number}, + ).first() + if not user: + return {"error": "USER_NOT_FOUND", "biz_number": biz_number} + + company_name = user._mapping["company_name"] + norm = _normalize_corp(company_name) + if not norm: + return {"error": "INVALID_COMPANY_NAME", "company_name": company_name} + + # ftc_brand_franchise 에서 corpNm 매칭 (정규화 ILIKE) + # frcsCnt 큰 row 부터 정렬 — 같은 brand 의 다년 데이터는 max 사용 + rows = c.execute( + sa.text( + """ + SELECT "brandNm", "indutyMlsfcNm", MAX("frcsCnt") AS stores + FROM ftc_brand_franchise + WHERE "corpNm" IS NOT NULL + AND REGEXP_REPLACE("corpNm", '\\(주\\)|㈜|주식회사|\\([^)]*\\)|\\s+', '', 'g') ILIKE :norm + GROUP BY "brandNm", "indutyMlsfcNm" + ORDER BY stores DESC NULLS LAST + """ + ), + {"norm": f"%{norm}%"}, + ).fetchall() + + if not rows: + return { + "error": "CORP_NOT_IN_FTC", + "company_name": company_name, + "message": f"{company_name} 은(는) FTC 가맹사업 정보공개서에 등록되지 않은 corp 입니다.", + } + + brands = [ + {"name": r._mapping["brandNm"], "industry": r._mapping["indutyMlsfcNm"], "stores": r._mapping["stores"] or 0} + for r in rows + ] + industries = sorted({b["industry"] for b in brands if b["industry"]}) + + return { + "company_name": company_name, + "brands": brands, + "industries": industries, + } + + +def resolve_brand_for_industry(biz_number: str, industry: str) -> dict: + """사업자번호 + 업종 → 같은 corp 의 해당 업종 가장 큰 brand 자동 선택. + + Args: + biz_number: 사업자등록번호. + industry: 업종명 (FTC indutyMlsfcNm 표기 — 한식/중식/일식/...). + + Returns: + 성공: ``{"brand_name": ..., "industry": ..., "stores": int, + "alternatives": [...], "company_name": ...}``. + 실패: ``{"error": "INDUSTRY_NOT_OPERATED" | "USER_NOT_FOUND" | "CORP_NOT_IN_FTC", + "operated_industries": [...], ...}``. + """ + portfolio = get_corp_industries(biz_number) + if "error" in portfolio: + return portfolio + + matched = [b for b in portfolio["brands"] if b["industry"] == industry] + if not matched: + return { + "error": "INDUSTRY_NOT_OPERATED", + "company_name": portfolio["company_name"], + "requested_industry": industry, + "operated_industries": portfolio["industries"], + "message": ( + f"'{industry}' 업종은 {portfolio['company_name']} 운영 brand 에 없습니다. " + f"운영 가능 업종: {', '.join(portfolio['industries'])}" + ), + } + + # frcsCnt 내림차순 정렬됨 (get_corp_industries 가 보장) — 첫 항목 = top brand + top = matched[0] + return { + "brand_name": top["name"], + "industry": top["industry"], + "stores": top["stores"], + "alternatives": [b["name"] for b in matched[1:]], + "company_name": portfolio["company_name"], + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49b90f78..bd0f06c6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -82,7 +82,7 @@ import { ToastHost } from './components/simulation/ToastHost'; import { useCompletionToast } from './hooks/useCompletionToast'; import { useAbmCompletionToast } from './hooks/useAbmCompletionToast'; import { useSimulationStore } from './stores/simulationStore'; -import { getLivePopulation } from './api/client'; +import { getLivePopulation, getOperatedIndustries } from './api/client'; import { useCombinedSimResult, buildCombinedResult } from './hooks/useCombinedSimResult'; import NetworkBackground from './components/NetworkBackground'; import WaveBackground from './pages/landing/WaveBackground'; @@ -781,6 +781,36 @@ function SimulatorDashboard({ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [brand?.industry_medium]); + + // 회사 운영 업종 list — mount 시 backend `/corp/operated-industries` 호출. + // null = 비회원/미등록 corp (모든 업종 허용), [] 또는 list = 그 list 만 enable. + // FTC indutyMlsfcNm 표기 (한식/서양식/제과제빵/주점/피자/...) → frontend 라벨 매핑은 + // 위 FTC_TO_FRONTEND_INDUSTRY 재사용. 동일 frontend 라벨로 매핑되는 FTC 업종이 + // 하나라도 운영 중이면 그 frontend 라벨 enable. + const [operatedFrontendLabels, setOperatedFrontendLabels] = useState | null>(null); + const [operatedCompanyName, setOperatedCompanyName] = useState(null); + useEffect(() => { + let cancelled = false; + getOperatedIndustries().then((res) => { + if (cancelled) return; + setOperatedCompanyName(res.company_name); + if (!res.industries) { + setOperatedFrontendLabels(null); // 모든 업종 허용 + return; + } + const labels = new Set(); + for (const ftc of res.industries) { + const mapped = FTC_TO_FRONTEND_INDUSTRY[ftc]; + if (mapped) labels.add(mapped); + } + setOperatedFrontendLabels(labels); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.id]); + const [businessTypeOpen, setBusinessTypeOpen] = useState(false); const [storeArea, setStoreArea] = useState(initParams?.store_area ?? 15); // 평 const [targetPrice, setTargetPrice] = useState(initParams?.target_price_range ?? '5to10k'); @@ -1186,22 +1216,41 @@ function SimulatorDashboard({ {businessTypeOpen && (
- {BUSINESS_TYPES.map((type) => ( - - ))} + {BUSINESS_TYPES.map((type) => { + const disabled = + operatedFrontendLabels !== null && !operatedFrontendLabels.has(type); + return ( + + ); + })}
)} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 19bb3062..396c8908 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -261,6 +261,30 @@ export async function getLivePopulation(dongs?: string[]): Promise { return response.data; } +/** + * 회사 운영 업종/브랜드 list — 시뮬 폼 mount 시 호출. + * 토큰의 user → users.biz_number → ftc_brand_franchise.corpNm 매칭 결과. + * + * - industries=null: 비회원 또는 corp 미등록 — 모든 업종 허용 + * - industries=[...] : 운영 업종만 enable, 그 외는 dropdown 에서 disable + */ +export interface OperatedIndustriesResponse { + company_name: string | null; + industries: string[] | null; + brands?: { name: string; industry: string; stores: number }[]; + error?: string; + message?: string; +} + +export async function getOperatedIndustries(): Promise { + try { + const response = await apiClient.get('/corp/operated-industries'); + return response.data as OperatedIndustriesResponse; + } catch { + return { company_name: null, industries: null, brands: [] }; + } +} + // ───────────────────────────────────────────────────────── // simulation_history (JWT Bearer 필수 — interceptor가 자동 주입) // ─────────────────────────────────────────────────────────