네이버 article에 cortarNo가 없는 경우 listing.dong_code가 None으로 저장되어 get_market_deals_for가 시세를 못 찾아 모든 매물이 '보류'(판정 불가) 상태였다. dong명은 항상 정확히 태깅되므로 lawd_code(dong)으로 5자리 시군구코드를 유도해 market_deals와 매칭시킨다. - internal_router.listings_ingest: dong_code 없으면 batch.dong에서 lawd_code로 보정 - db.upsert_listing: ON CONFLICT UPDATE SET에 dong_code 추가(재유입 시 기존 None행 갱신) - listing_matcher.run_listing_matching: get_market_deals_for 호출 전 dong_code 없으면 listing.dong에서 즉시 유도(기존 저장된 None행도 재실행만으로 판정 복구) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
112 lines
6.0 KiB
Python
112 lines
6.0 KiB
Python
import statistics
|
|
def _deals(vals, key="deposit", dt="전세"):
|
|
return [{key: v, "deal_type": dt} for v in vals]
|
|
|
|
def test_compute_safety_tiers():
|
|
from app.listing_matcher import compute_safety
|
|
deals = _deals([40000, 42000, 44000, 43000]) # median ~42500
|
|
assert compute_safety(28000, "전세", deals)["tier"] == "안전" # 0.66
|
|
assert compute_safety(32000, "전세", deals)["tier"] == "주의" # 0.75
|
|
assert compute_safety(38000, "전세", deals)["tier"] == "위험" # 0.89
|
|
assert compute_safety(28000, "전세", _deals([40000, 41000]))["tier"] == "보류" # 표본<3
|
|
|
|
def test_compute_valuation_tiers():
|
|
from app.listing_matcher import compute_valuation
|
|
deals = _deals([80000, 88000, 90000, 87000], key="sale_amount", dt="매매") # median ~87500
|
|
assert compute_valuation(83000, deals)["tier"] == "저평가" # 0.95
|
|
assert compute_valuation(89000, deals)["tier"] == "시세" # 1.02
|
|
assert compute_valuation(95000, deals)["tier"] == "고가" # 1.09
|
|
|
|
def test_match_listing_rent_deposit_cap():
|
|
from app.listing_matcher import match_listing
|
|
crit = {"dongs": ["신대방동"], "deal_types": ["전세"], "max_deposit": 32000,
|
|
"min_area": 40.0, "house_types": ["아파트"]}
|
|
ok = match_listing(crit, {"deal_type": "전세", "deposit": 29000, "area_exclusive": 42.0,
|
|
"dong": "신대방동"}, budget={"max_deposit": 45000})
|
|
assert ok["passed"] is True
|
|
over = match_listing(crit, {"deal_type": "전세", "deposit": 40000, "area_exclusive": 42.0,
|
|
"dong": "신대방동"}, budget={"max_deposit": 45000})
|
|
assert over["passed"] is False
|
|
|
|
|
|
# ── Task 2: run_listing_matching 단일 conn 통합 테스트 ───────────────────────
|
|
|
|
def test_run_listing_matching_end_to_end_safe_rent_and_overpriced_sale():
|
|
"""listings+market_deals+criteria를 시드하고 run_listing_matching()을 돌려
|
|
listing_matches가 실제로 생성되고 tier/passed가 기대대로인지 확인한다
|
|
(단일 connection 리팩터 후에도 판정 로직이 보존됐는지 검증하는 회귀 테스트)."""
|
|
from app import db
|
|
from app.listing_matcher import run_listing_matching
|
|
|
|
# criteria: 신대방동 전세/매매, 보증금 상한 32000, 최소면적 40
|
|
db.update_listing_criteria({
|
|
"dongs": ["신대방동"], "deal_types": ["전세", "매매"],
|
|
"max_deposit": 32000, "min_area": 40.0,
|
|
"house_types": ["아파트"], "equity": 20000, "annual_income": 6000,
|
|
})
|
|
|
|
# 전세 실거래 시세(median ~42000) — 안전 매물(28000, ratio 0.67)
|
|
for i, v in enumerate((40000, 42000, 44000)):
|
|
db.upsert_market_deal({"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
|
|
"complex_name": "OO", "area": 42.0, "deal_type": "전세",
|
|
"deposit": v, "sale_amount": None,
|
|
"deal_ym": f"2026{i+1:02d}", "floor": "5", "source": "molit"})
|
|
l_rent, _ = db.upsert_listing({"article_no": "R1", "source": "naver", "deal_type": "전세",
|
|
"deposit": 28000, "area_exclusive": 42.0,
|
|
"complex_name": "OO", "dong": "신대방동", "dong_code": "11590"})
|
|
|
|
# 매매 실거래 시세(median ~87500) — 고가 매물(95000, ratio 1.09)
|
|
for i, v in enumerate((80000, 88000, 90000, 87000)):
|
|
db.upsert_market_deal({"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
|
|
"complex_name": "PP", "area": 59.0, "deal_type": "매매",
|
|
"deposit": None, "sale_amount": v,
|
|
"deal_ym": f"2026{i+1:02d}", "floor": "10", "source": "molit"})
|
|
l_sale, _ = db.upsert_listing({"article_no": "R2", "source": "naver", "deal_type": "매매",
|
|
"sale_price": 95000, "area_exclusive": 59.0,
|
|
"complex_name": "PP", "dong": "신대방동", "dong_code": "11590"})
|
|
|
|
run_listing_matching()
|
|
|
|
matches = {m["listing_id"]: m for m in db.get_listing_matches()}
|
|
assert len(matches) == 2
|
|
rent_m = matches[l_rent["id"]]
|
|
assert rent_m["passed"] == 1
|
|
assert rent_m["safety_tier"] == "안전"
|
|
assert rent_m["sample_size"] == 3
|
|
|
|
sale_m = matches[l_sale["id"]]
|
|
assert sale_m["valuation_tier"] == "고가"
|
|
assert sale_m["sample_size"] == 4
|
|
|
|
|
|
# ── 근본원인 fix: 기존 저장된 listing.dong_code=None(cortarNo 부재) 행도 dong명에서
|
|
# lawd_code를 유도해 즉시 판정돼야 한다(보류 고착 방지) ──
|
|
|
|
def test_run_listing_matching_derives_dong_code_when_none():
|
|
from app import db
|
|
from app.listing_matcher import run_listing_matching
|
|
|
|
db.update_listing_criteria({
|
|
"dongs": ["신길동"], "deal_types": ["매매"],
|
|
"min_area": 40.0, "house_types": ["아파트"],
|
|
"equity": 20000, "annual_income": 6000,
|
|
})
|
|
|
|
# 신길동(영등포구=11560) 매매 실거래 시세 3건
|
|
for i, v in enumerate((80000, 88000, 90000)):
|
|
db.upsert_market_deal({"house_type": "아파트", "dong_code": "11560", "dong": "신길동",
|
|
"complex_name": "QQ", "area": 59.0, "deal_type": "매매",
|
|
"deposit": None, "sale_amount": v,
|
|
"deal_ym": f"2026{i+1:02d}", "floor": "8", "source": "molit"})
|
|
# 매물은 dong_code=None으로 저장(네이버 cortarNo 부재 시나리오)
|
|
l_sale, _ = db.upsert_listing({"article_no": "R3", "source": "naver", "deal_type": "매매",
|
|
"sale_price": 84000, "area_exclusive": 59.0,
|
|
"complex_name": "QQ", "dong": "신길동", "dong_code": None})
|
|
|
|
run_listing_matching()
|
|
|
|
matches = {m["listing_id"]: m for m in db.get_listing_matches()}
|
|
m = matches[l_sale["id"]]
|
|
assert m["sample_size"] == 3
|
|
assert m["valuation_tier"] != "보류"
|