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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 62 additions & 8 deletions backend/src/services/brand_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,33 @@

from __future__ import annotations

import logging
import os
from typing import TypedDict

from sqlalchemy import text

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
Expand Down Expand Up @@ -46,22 +64,44 @@ 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",
"avrgSlsAmt", "arUnitAvrgSlsAmt"
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 {
Expand All @@ -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"],
Expand All @@ -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"],
}
Expand Down
32 changes: 32 additions & 0 deletions docs/retrospective/2026-05-05.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(-)
```

---
Loading