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_profile, upsert_profile, get_matches, mark_match_read,
|
||||||
get_last_collect_log, get_dashboard,
|
get_last_collect_log, get_dashboard,
|
||||||
delete_old_completed_announcements,
|
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 .collector import collect_all
|
||||||
from .matcher import run_matching
|
from .matcher import run_matching
|
||||||
from .notifier import notify_new_matches
|
from .notifier import notify_new_matches, notify_new_listings
|
||||||
from .models import AnnouncementCreate, AnnouncementUpdate, ProfileUpdate
|
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")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||||
logger = logging.getLogger("realestate-lab")
|
logger = logging.getLogger("realestate-lab")
|
||||||
@@ -56,12 +65,29 @@ def scheduled_status_update():
|
|||||||
logger.info("상태 갱신 + 재매칭 완료")
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
init_db()
|
init_db()
|
||||||
# 09:00 cron 스태거링 — agent-office 09:00/05/10 이후 (CHECK_POINT FU-A)
|
# 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_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_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()
|
scheduler.start()
|
||||||
logger.info("realestate-lab 시작")
|
logger.info("realestate-lab 시작")
|
||||||
yield
|
yield
|
||||||
@@ -217,3 +243,66 @@ def api_match_read(match_id: int):
|
|||||||
@app.get("/api/realestate/dashboard")
|
@app.get("/api/realestate/dashboard")
|
||||||
def api_dashboard():
|
def api_dashboard():
|
||||||
return get_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
|
preferred_districts: Optional[Dict[str, List[str]]] = None
|
||||||
min_match_score: Optional[int] = Field(default=None, ge=0, le=100)
|
min_match_score: Optional[int] = Field(default=None, ge=0, le=100)
|
||||||
notify_enabled: Optional[bool] = None
|
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