feat(realestate): 매물 라우트 6종+safety-check/budget+3h cron
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
33
realestate-lab/tests/test_listing_api.py
Normal file
33
realestate-lab/tests/test_listing_api.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user