From b4fb3998fe84e2a3a63527d8021616f93f99b476 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 13:43:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(realestate):=20=EB=A7=A4=EB=AC=BC=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8A=B8=206=EC=A2=85+safety-check/budget+3h?= =?UTF-8?q?=20cron?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9 --- realestate-lab/app/main.py | 93 +++++++++++++++++++++++- realestate-lab/app/models.py | 35 +++++++++ realestate-lab/tests/test_listing_api.py | 33 +++++++++ 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 realestate-lab/tests/test_listing_api.py diff --git a/realestate-lab/app/main.py b/realestate-lab/app/main.py index 5a6a538..28542df 100644 --- a/realestate-lab/app/main.py +++ b/realestate-lab/app/main.py @@ -15,11 +15,20 @@ from .db import ( get_profile, upsert_profile, get_matches, mark_match_read, get_last_collect_log, get_dashboard, delete_old_completed_announcements, + get_listing_criteria, update_listing_criteria, get_listings, get_listing_matches, + get_last_listing_collect_log, get_market_deals_for, ) from .collector import collect_all from .matcher import run_matching -from .notifier import notify_new_matches -from .models import AnnouncementCreate, AnnouncementUpdate, ProfileUpdate +from .notifier import notify_new_matches, notify_new_listings +from .listing_collector import collect_listings +from .listing_matcher import run_listing_matching, compute_safety, compute_valuation +from . import finance_rules +from .finance_rules_config import DISCLAIMER +from .models import ( + AnnouncementCreate, AnnouncementUpdate, ProfileUpdate, + ListingCriteriaUpdate, SafetyCheckRequest, BudgetRequest, +) logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s") logger = logging.getLogger("realestate-lab") @@ -56,12 +65,29 @@ def scheduled_status_update(): logger.info("상태 갱신 + 재매칭 완료") +_listing_collect_lock = threading.Lock() + + +def _run_listing_pipeline(): + if not _listing_collect_lock.acquire(blocking=False): + logger.info("매물 수집 이미 진행 중 — 건너뜀"); return + try: + collect_listings(); run_listing_matching(); notify_new_listings() + finally: + _listing_collect_lock.release() + + +def scheduled_listing_collect(): + logger.info("매물 스케줄 수집 시작"); _run_listing_pipeline() + + @asynccontextmanager async def lifespan(app: FastAPI): init_db() # 09:00 cron 스태거링 — agent-office 09:00/05/10 이후 (CHECK_POINT FU-A) scheduler.add_job(scheduled_collect, "cron", hour=9, minute=15, id="collect") scheduler.add_job(scheduled_status_update, "cron", hour=0, minute=0, id="status_update") + scheduler.add_job(scheduled_listing_collect, "cron", hour="8,11,14,17,20", minute=40, id="listing_collect") scheduler.start() logger.info("realestate-lab 시작") yield @@ -217,3 +243,66 @@ def api_match_read(match_id: int): @app.get("/api/realestate/dashboard") def api_dashboard(): return get_dashboard() + + +# ── 매물 API ───────────────────────────────────────────────────────────────── + +@app.get("/api/realestate/listings") +def api_listings(dong: str = None, deal_type: str = None, tier: str = None, + matched_only: bool = False, page: int = 1, size: int = 50): + return {"listings": get_listings(dong, deal_type, tier, matched_only, size, (page - 1) * size)} + + +@app.post("/api/realestate/listings/collect") +def api_listings_collect(background_tasks: BackgroundTasks): + background_tasks.add_task(_run_listing_pipeline) + return {"ok": True, "message": "매물 수집 시작됨"} + + +@app.get("/api/realestate/listings/collect/status") +def api_listings_collect_status(): + return get_last_listing_collect_log() or {"status": "never_run"} + + +@app.get("/api/realestate/listings/criteria") +def api_get_listing_criteria(): + return get_listing_criteria() + + +@app.put("/api/realestate/listings/criteria") +def api_put_listing_criteria(req: ListingCriteriaUpdate): + update_listing_criteria(req.model_dump(exclude_none=True)) + return get_listing_criteria() + + +@app.get("/api/realestate/listings/matches") +def api_listing_matches(): + return {"matches": get_listing_matches()} + + +@app.post("/api/realestate/safety-check") +def api_safety_check(req: SafetyCheckRequest): + deals = get_market_deals_for(req.dong_code, req.complex_name, req.area, + "전세" if req.deal_type != "매매" else "매매") + reg = finance_rules.region_regulation(req.dong) # dong명(없으면 토허 판정 생략) + if req.deal_type == "매매": + res = compute_valuation(req.amount, deals) + ratio_key = "price_ratio" + else: + res = compute_safety(req.amount, req.deal_type, deals) + ratio_key = "jeonse_ratio" + return {"median": res["median"], "ratio": res.get(ratio_key), "tier": res["tier"], + "sample": res["sample"], "is_toheo": reg["is_toheo"], + "disclaimer": "등기부 선순위·토허 허가·실거주 의무는 계약 전 수동 확인 필수. " + DISCLAIMER} + + +@app.post("/api/realestate/budget") +def api_budget(req: BudgetRequest): + reg = finance_rules.region_regulation(req.target_dong) + return { + "jeonse": finance_rules.estimate_jeonse_budget(req.equity, req.annual_income), + "purchase": finance_rules.estimate_purchase_budget(req.equity, req.annual_income, reg, + req.is_first_home, req.is_homeless), + "region": reg, + "disclaimer": DISCLAIMER, + } diff --git a/realestate-lab/app/models.py b/realestate-lab/app/models.py index 8c1c460..3a9bd10 100644 --- a/realestate-lab/app/models.py +++ b/realestate-lab/app/models.py @@ -84,3 +84,38 @@ class ProfileUpdate(BaseModel): preferred_districts: Optional[Dict[str, List[str]]] = None min_match_score: Optional[int] = Field(default=None, ge=0, le=100) notify_enabled: Optional[bool] = None + + +class ListingCriteriaUpdate(BaseModel): + dongs: Optional[List[str]] = None + deal_types: Optional[List[str]] = None + max_deposit: Optional[int] = None + max_sale_price: Optional[int] = None + min_area: Optional[float] = None + house_types: Optional[List[str]] = None + min_safety_tier: Optional[str] = None + notify_enabled: Optional[bool] = None + equity: Optional[int] = None + annual_income: Optional[int] = None + is_homeless: Optional[bool] = None + is_householder: Optional[bool] = None + is_first_home: Optional[bool] = None + + +class SafetyCheckRequest(BaseModel): + complex_name: Optional[str] = None + address: Optional[str] = None + dong: Optional[str] = None + dong_code: Optional[str] = None + area: float + deal_type: str + amount: int + + +class BudgetRequest(BaseModel): + equity: int + annual_income: int + is_homeless: bool = True + is_householder: bool = False + is_first_home: bool = False + target_dong: Optional[str] = None diff --git a/realestate-lab/tests/test_listing_api.py b/realestate-lab/tests/test_listing_api.py new file mode 100644 index 0000000..f87e27d --- /dev/null +++ b/realestate-lab/tests/test_listing_api.py @@ -0,0 +1,33 @@ +from fastapi.testclient import TestClient + +def _client(): + from app.main import app + return TestClient(app) + +def test_criteria_get_default_and_put(): + c = _client() + assert c.get("/api/realestate/listings/criteria").json()["max_deposit"] == 32000 + c.put("/api/realestate/listings/criteria", json={"max_deposit": 30000}) + assert c.get("/api/realestate/listings/criteria").json()["max_deposit"] == 30000 + +def test_budget_endpoint(): + r = _client().post("/api/realestate/budget", json={"equity": 30000, "annual_income": 8000, + "is_homeless": True, "is_householder": True, "is_first_home": False, "target_dong": "상도동"}) + b = r.json() + assert "jeonse" in b and "purchase" in b and "region" in b + assert b["purchase"]["max_price"] == b["purchase"]["loan_cap"] + 30000 + +def test_safety_check_rent(): + from app import db + for v in (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": "202606", "floor": "5"}) + r = _client().post("/api/realestate/safety-check", + json={"complex_name": "OO", "area": 42.0, "deal_type": "전세", + "amount": 28000, "dong_code": "11590"}) + j = r.json() + assert j["tier"] == "안전" and "disclaimer" in j + +def test_listings_collect_status(): + assert _client().get("/api/realestate/listings/collect/status").status_code == 200