From f676116336ee421a790dded14e236e54981edd47 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 10 Jul 2026 14:11:51 +0900 Subject: [PATCH] =?UTF-8?q?fix(realestate):=20=EB=84=A4=EC=9D=B4=EB=B2=84?= =?UTF-8?q?=20=EB=A7=A4=EB=AC=BC=20dong=5Fcode=EB=A5=BC=20dong=EB=AA=85?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9C=A0=EB=8F=84(cortarNo=20=EB=B6=80?= =?UTF-8?q?=EC=9E=AC)=E2=86=92=20market=5Fdeals=20=EB=A7=A4=EC=B9=AD=C2=B7?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 네이버 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) Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9 --- realestate-lab/app/db.py | 3 +- realestate-lab/app/internal_router.py | 5 ++- realestate-lab/app/listing_matcher.py | 6 +++- realestate-lab/tests/test_internal_ingest.py | 20 ++++++++++++ realestate-lab/tests/test_listing_matcher.py | 32 ++++++++++++++++++++ 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/realestate-lab/app/db.py b/realestate-lab/app/db.py index 4a82629..b5e5e29 100644 --- a/realestate-lab/app/db.py +++ b/realestate-lab/app/db.py @@ -977,7 +977,8 @@ def upsert_listing(data: Dict[str, Any]) -> tuple: ON CONFLICT(article_no) DO UPDATE SET deposit=excluded.deposit, monthly_rent=excluded.monthly_rent, sale_price=excluded.sale_price, floor=excluded.floor, complex_name=excluded.complex_name, address=excluded.address, - url=excluded.url, posted_at=excluded.posted_at, raw_json=excluded.raw_json + url=excluded.url, posted_at=excluded.posted_at, raw_json=excluded.raw_json, + dong_code=excluded.dong_code """, d) row = conn.execute("SELECT * FROM listings WHERE article_no=?", (d["article_no"],)).fetchone() return dict(row), is_new diff --git a/realestate-lab/app/internal_router.py b/realestate-lab/app/internal_router.py index 88123de..2916ce5 100644 --- a/realestate-lab/app/internal_router.py +++ b/realestate-lab/app/internal_router.py @@ -8,7 +8,7 @@ from pydantic import BaseModel from .auth import verify_internal_key from .db import get_listing_criteria, upsert_listing -from .lawd_codes import naver_cortar +from .lawd_codes import naver_cortar, lawd_code from .listing_collector import _parse_naver_article from .listing_matcher import run_listing_matching from .notifier import notify_new_listings @@ -55,6 +55,9 @@ def listings_ingest(body: ListingsIngest, background_tasks: BackgroundTasks): if not d.get("article_no"): continue d["dong"] = batch.dong + # 네이버 article에 cortarNo가 없는 경우가 실사용에서 확인됨 → dong명에서 + # 5자리 시군구코드(lawd_code)를 유도(market_deals는 이 5자리를 쓴다). + d["dong_code"] = d.get("dong_code") or lawd_code(batch.dong) _, is_new = upsert_listing(d) received += 1 if is_new: diff --git a/realestate-lab/app/listing_matcher.py b/realestate-lab/app/listing_matcher.py index d9c7644..4800623 100644 --- a/realestate-lab/app/listing_matcher.py +++ b/realestate-lab/app/listing_matcher.py @@ -5,6 +5,7 @@ import statistics from .db import (get_listing_criteria, get_market_deals_for, bulk_upsert_listing_matches, _conn) from . import finance_rules +from .lawd_codes import lawd_code logger = logging.getLogger("realestate-lab") @@ -96,7 +97,10 @@ def run_listing_matching() -> None: budget = finance_rules.estimate_purchase_budget( equity, income, reg, criteria.get("is_first_home"), criteria.get("is_homeless")) m = match_listing(criteria, l, budget) - deals = get_market_deals_for(l.get("dong_code"), l.get("complex_name"), + # 네이버 cortarNo 부재 등으로 기존 저장된 listing.dong_code가 None일 수 있음 — + # dong명에서 5자리 lawd_code를 유도해 즉시 판정(보류 고착 방지). + dc = l.get("dong_code") or lawd_code(l.get("dong")) + deals = get_market_deals_for(dc, l.get("complex_name"), l.get("area_exclusive"), "전세" if is_rent else "매매", conn=conn) rec = {"listing_id": l["id"], "category": "임차" if is_rent else "매매", diff --git a/realestate-lab/tests/test_internal_ingest.py b/realestate-lab/tests/test_internal_ingest.py index 37b01cf..28797c6 100644 --- a/realestate-lab/tests/test_internal_ingest.py +++ b/realestate-lab/tests/test_internal_ingest.py @@ -59,3 +59,23 @@ def test_ingest_holds_match_notify_lock(monkeypatch): c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"}, json={"batches": [{"dong": "신대방동", "articles": [_ARTICLE]}]}) assert holds["locked"] is True # 알림 시점에 락 보유(임계구역 직렬화 증명) + + +# ── 근본원인 fix: 네이버 article에 cortarNo 없으면 dong_code=None → 시세 매칭 불가 ── + +_ARTICLE_NO_CORTAR = {"articleNo": "N200", "tradeTypeName": "전세", "dealOrWarrantPrc": "2억 9,000", + "area2": "42.5", "floorInfo": "5/15", "articleName": "OO아파트", + "buildingName": "OO"} # cortarNo 없음 — 실제 네이버 응답 케이스 + + +def test_ingest_derives_dong_code_from_dong_when_cortarno_missing(monkeypatch): + c = _client(monkeypatch) + with patch("app.internal_router.notify_new_listings", return_value={"sent": 0}): + r = c.post("/api/internal/realestate/listings-ingest", + headers={"X-Internal-Key": "SECRET"}, + json={"batches": [{"dong": "신길동", "articles": [_ARTICLE_NO_CORTAR]}]}) + assert r.status_code == 200 + from app import db + ls = db.get_listings() + saved = next(l for l in ls if l["article_no"] == "N200") + assert saved["dong_code"] == "11560" # 신길동 → 영등포구 5자리 lawd_code로 유도 diff --git a/realestate-lab/tests/test_listing_matcher.py b/realestate-lab/tests/test_listing_matcher.py index 5cdf517..f8af228 100644 --- a/realestate-lab/tests/test_listing_matcher.py +++ b/realestate-lab/tests/test_listing_matcher.py @@ -77,3 +77,35 @@ def test_run_listing_matching_end_to_end_safe_rent_and_overpriced_sale(): 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"] != "보류"