diff --git a/backend/src/services/brand_profile.py b/backend/src/services/brand_profile.py index e2d4c31a..0ddd0367 100644 --- a/backend/src/services/brand_profile.py +++ b/backend/src/services/brand_profile.py @@ -8,6 +8,7 @@ from __future__ import annotations +import logging import os from typing import TypedDict @@ -15,8 +16,25 @@ from src.database.sync_engine import get_sync_engine +logger = logging.getLogger(__name__) + DEFAULT_YEAR = 2024 # FTC 2024 공시 기준 (2025 데이터는 2026 하반기 발표 예정) +# Module-level fuzzy SQL — text() 객체 매 호출마다 재생성 회피. +_FUZZY_SQL = text( + """ + SELECT "corpNm", "brandNm", "indutyLclasNm", "indutyMlsfcNm", + "frcsCnt", "newFrcsRgsCnt", "ctrtEndCnt", "ctrtCncltnCnt", "nmChgCnt", + "avrgSlsAmt", "arUnitAvrgSlsAmt" + FROM ftc_brand_franchise + WHERE "brandNm" ILIKE :pattern + AND yr = :year + AND ("frcsCnt" IS NULL OR "frcsCnt" > 0) + ORDER BY "frcsCnt" DESC NULLS LAST + LIMIT 1 + """ +) + class BrandBenchmark(TypedDict, total=False): brand_name: str @@ -46,9 +64,18 @@ def _won_from_thousand(val: int | None) -> int | None: def get_brand_benchmark(brand_name: str, year: int = DEFAULT_YEAR) -> BrandBenchmark: """FTC 가맹본부 공시에서 브랜드 연간 실적 조회. + 매칭 우선순위: + 1. 정확 일치 (`brandNm = :brand`) + 2. 1차 실패 시 fuzzy 매칭 (예: '홍콩반점' → '홍콩반점0410') — ILIKE prefix + frcsCnt 큰 것 우선 + 3. 매칭 row 의 frcsCnt = 0 이면 benchmark_available=False (closure_rate/growth_rate 계산 불가) + FTC 미등재 (직영 브랜드 등) 시 benchmark_available=False. """ - sql = text( + engine = get_sync_engine(os.environ["POSTGRES_URL"]) + + # 1차: 정확 일치 — 동일 (brandNm, yr) 다 row 시 silent tie-break 회피. + # 복수 row 발견 시 첫 row 반환 + WARNING 로그 (FTC 원본 brand명 변경 중간 단계 등 데이터 품질 신호). + exact_sql = text( """ SELECT "corpNm", "brandNm", "indutyLclasNm", "indutyMlsfcNm", "frcsCnt", "newFrcsRgsCnt", "ctrtEndCnt", "ctrtCncltnCnt", "nmChgCnt", @@ -56,12 +83,25 @@ def get_brand_benchmark(brand_name: str, year: int = DEFAULT_YEAR) -> BrandBench FROM ftc_brand_franchise WHERE "brandNm" = :brand AND yr = :year - LIMIT 1 """ ) - engine = get_sync_engine(os.environ["POSTGRES_URL"]) with engine.connect() as conn: - row = conn.execute(sql, {"brand": brand_name, "year": year}).mappings().first() + all_exact = conn.execute(exact_sql, {"brand": brand_name, "year": year}).mappings().all() + if len(all_exact) > 1: + logger.warning( + "[brand_profile] FTC 동일 brandNm 복수 row 감지 brand=%s yr=%d count=%d corps=%s", + brand_name, + year, + len(all_exact), + [r["corpNm"] for r in all_exact], + ) + row = all_exact[0] if all_exact else None + + # 2차: fuzzy prefix 매칭 — 정확 매칭 실패 시 접미 변형 흡수 (예: '홍콩반점' → '홍콩반점0410'). + # 위험 (false positive): '파리' → '파리바게뜨'/'파리크라상' 같은 별 brand 잘못 매칭 가능. + # 가드: 길이 ≥ 4 (한글 4자 이상은 prefix 충돌 거의 없음) + frcsCnt > 0 우선. + if row is None and brand_name and len(brand_name) >= 4: + row = conn.execute(_FUZZY_SQL, {"pattern": f"{brand_name}%", "year": year}).mappings().first() if not row: return { @@ -72,8 +112,22 @@ def get_brand_benchmark(brand_name: str, year: int = DEFAULT_YEAR) -> BrandBench } frcs = row["frcsCnt"] or 0 - closure_rate = (row["ctrtEndCnt"] or 0) / frcs if frcs else None - growth_rate = (row["newFrcsRgsCnt"] or 0) / frcs if frcs else None + + # frcsCnt = 0 (paper brand) → 계산 불가 명시. None * 100 등 다운스트림 폭발 방지. + if frcs == 0: + return { + "brand_name": row["brandNm"], + "benchmark_available": False, + "reason": f"FTC 등재됐으나 가맹점 0 (paper brand). corp={row['corpNm']!s}, yr={year}. 통계 계산 불가.", + "reference_year": year, + "corp_name": row["corpNm"], + "franchise_count_national": 0, + "industry_large": row["indutyLclasNm"], + "industry_medium": row["indutyMlsfcNm"], + } + + closure_rate = (row["ctrtEndCnt"] or 0) / frcs + growth_rate = (row["newFrcsRgsCnt"] or 0) / frcs return { "brand_name": row["brandNm"], @@ -87,8 +141,8 @@ def get_brand_benchmark(brand_name: str, year: int = DEFAULT_YEAR) -> BrandBench "closed_contracts": row["ctrtEndCnt"], "cancelled_contracts": row["ctrtCncltnCnt"], "name_changes": row["nmChgCnt"], - "closure_rate": round(closure_rate, 4) if closure_rate is not None else None, - "growth_rate": round(growth_rate, 4) if growth_rate is not None else None, + "closure_rate": round(closure_rate, 4), + "growth_rate": round(growth_rate, 4), "industry_large": row["indutyLclasNm"], "industry_medium": row["indutyMlsfcNm"], } diff --git a/docs/retrospective/2026-05-05.md b/docs/retrospective/2026-05-05.md index 68f5afa1..1208652a 100644 --- a/docs/retrospective/2026-05-05.md +++ b/docs/retrospective/2026-05-05.md @@ -450,3 +450,35 @@ ``` --- + +## 11:04:25 세션 완료 + +### 변경 파일 +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + docs/retrospective/2026-05-05.md | 8 ++++++++ + 1 file changed, 8 insertions(+) +``` + +--- + +## 11:35:51 세션 완료 + +### 변경 파일 +- backend/src/services/brand_profile.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AgentMapVisualizer.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + backend/src/services/brand_profile.py | 54 +++++++++++++++++++--- + docs/retrospective/2026-05-05.md | 24 ++++++++++ + frontend/src/components/AgentMapVisualizer.tsx | 2 +- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 4 +- + 4 files changed, 75 insertions(+), 9 deletions(-) +``` + +---