diff --git a/realestate-lab/app/collector.py b/realestate-lab/app/collector.py index 7a47fb9..7c583e2 100644 --- a/realestate-lab/app/collector.py +++ b/realestate-lab/app/collector.py @@ -145,6 +145,13 @@ def collect_all() -> Dict[str, Any]: for raw in detail_rows: try: parsed = _parse_apt_detail(raw) + # 일정 정보가 하나도 없는 공고는 건너뜀 + has_dates = any(parsed.get(f) for f in ( + "receipt_start", "receipt_end", "spsply_start", + "gnrl_rank1_start", "winner_date", "contract_start", + )) + if not has_dates: + continue _, is_new = upsert_announcement(parsed) total_count += 1 if is_new: diff --git a/realestate-lab/app/db.py b/realestate-lab/app/db.py index 2bba4d2..6b96377 100644 --- a/realestate-lab/app/db.py +++ b/realestate-lab/app/db.py @@ -53,6 +53,7 @@ def init_db(): is_price_cap TEXT, contact TEXT, status TEXT NOT NULL DEFAULT '청약예정', + is_bookmarked INTEGER NOT NULL DEFAULT 0, source TEXT NOT NULL DEFAULT 'manual', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), @@ -62,6 +63,12 @@ def init_db(): conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_status ON announcements(status);") conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_region ON announcements(region_name);") + # ── 마이그레이션: is_bookmarked 컬럼 추가 ── + try: + conn.execute("SELECT is_bookmarked FROM announcements LIMIT 1") + except Exception: + conn.execute("ALTER TABLE announcements ADD COLUMN is_bookmarked INTEGER NOT NULL DEFAULT 0") + # ── announcement_models ────────────────────────────────────────── conn.execute(""" CREATE TABLE IF NOT EXISTS announcement_models ( @@ -228,25 +235,45 @@ def upsert_announcement(data: Dict[str, Any]) -> tuple: return _ann_row_to_dict(row), is_new +def _enrich_with_price(conn, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """공고 목록에 모델 기반 가격 범위를 추가한다.""" + for item in items: + hmno = item.get("house_manage_no") + pno = item.get("pblanc_no") + if hmno and pno: + price_row = conn.execute( + "SELECT MIN(top_amount) as min_price, MAX(top_amount) as max_price " + "FROM announcement_models WHERE house_manage_no = ? AND pblanc_no = ? AND top_amount IS NOT NULL", + (hmno, pno), + ).fetchone() + if price_row and price_row["min_price"] is not None: + item["min_price"] = price_row["min_price"] + item["max_price_display"] = price_row["max_price"] + return items + + def get_announcements( region: str = None, status: str = None, house_type: str = None, matched_only: bool = False, + bookmarked: bool = False, sort: str = "date", page: int = 1, size: int = 20, ) -> Dict[str, Any]: conditions, params = [], [] if region: - conditions.append("a.region_name = ?") - params.append(region) + conditions.append("a.region_name LIKE ?") + params.append(f"%{region}%") if status: conditions.append("a.status = ?") params.append(status) if house_type: conditions.append("a.house_secd = ?") params.append(house_type) + if bookmarked: + conditions.append("a.is_bookmarked = 1") if matched_only: conditions.append("a.id IN (SELECT announcement_id FROM match_results)") @@ -268,8 +295,10 @@ def get_announcements( f"SELECT a.* FROM announcements a {where} ORDER BY {order} LIMIT ? OFFSET ?", params + [size, offset], ).fetchall() + items = [_ann_row_to_dict(r) for r in rows] + items = _enrich_with_price(conn, items) return { - "items": [_ann_row_to_dict(r) for r in rows], + "items": items, "total": total, "page": page, "size": size, @@ -339,6 +368,20 @@ def update_announcement(ann_id: int, data: Dict[str, Any]) -> Optional[Dict[str, return get_announcement(ann_id) +def toggle_bookmark(ann_id: int) -> Optional[Dict[str, Any]]: + with _conn() as conn: + row = conn.execute("SELECT id, is_bookmarked FROM announcements WHERE id = ?", (ann_id,)).fetchone() + if not row: + return None + new_val = 0 if row["is_bookmarked"] else 1 + conn.execute( + "UPDATE announcements SET is_bookmarked = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", + (new_val, ann_id), + ) + updated = conn.execute("SELECT * FROM announcements WHERE id = ?", (ann_id,)).fetchone() + return _ann_row_to_dict(updated) + + def delete_announcement(ann_id: int) -> bool: with _conn() as conn: # match_results는 FK CASCADE로 자동 삭제 @@ -532,15 +575,54 @@ def get_dashboard() -> Dict[str, Any]: new_matches = conn.execute( "SELECT COUNT(*) FROM match_results WHERE is_new = 1" ).fetchone()[0] - upcoming = conn.execute(""" - SELECT id, house_nm, receipt_start, receipt_end, status + bookmarked_count = conn.execute( + "SELECT COUNT(*) FROM announcements WHERE is_bookmarked = 1" + ).fetchone()[0] + + # 다가오는 일정을 개별 이벤트로 분해 + upcoming_rows = conn.execute(""" + SELECT id, house_nm, receipt_start, receipt_end, + spsply_start, gnrl_rank1_start, winner_date, + contract_start, status FROM announcements WHERE status IN ('청약예정', '청약중') ORDER BY receipt_start ASC - LIMIT 5 + LIMIT 20 """).fetchall() + + today = date.today().isoformat() + schedules = [] + for r in upcoming_rows: + events = [ + ("특별공급 접수", r["spsply_start"]), + ("1순위 접수", r["gnrl_rank1_start"]), + ("청약 접수", r["receipt_start"]), + ("당첨자 발표", r["winner_date"]), + ("계약 시작", r["contract_start"]), + ] + for event, d in events: + if d and d >= today: + schedules.append({ + "announcement_id": r["id"], + "house_nm": r["house_nm"], + "event": event, + "date": d, + }) + schedules.sort(key=lambda s: s["date"]) + schedules = schedules[:10] + + # 즐겨찾기 공고 + bookmarked_rows = conn.execute(""" + SELECT * FROM announcements WHERE is_bookmarked = 1 + ORDER BY receipt_start ASC + """).fetchall() + bookmarked_items = [_ann_row_to_dict(r) for r in bookmarked_rows] + bookmarked_items = _enrich_with_price(conn, bookmarked_items) + return { "active_count": active, "new_match_count": new_matches, - "upcoming": [dict(r) for r in upcoming], + "bookmarked_count": bookmarked_count, + "upcoming_schedules": schedules, + "bookmarked": bookmarked_items, } diff --git a/realestate-lab/app/main.py b/realestate-lab/app/main.py index 4066239..9edf760 100644 --- a/realestate-lab/app/main.py +++ b/realestate-lab/app/main.py @@ -8,7 +8,8 @@ from apscheduler.schedulers.background import BackgroundScheduler from .db import ( init_db, get_announcements, get_announcement, create_announcement, - update_announcement, delete_announcement, update_all_statuses, + update_announcement, delete_announcement, toggle_bookmark, + update_all_statuses, get_profile, upsert_profile, get_matches, mark_match_read, get_last_collect_log, get_dashboard, ) @@ -74,11 +75,12 @@ def api_announcements( status: str = None, house_type: str = None, matched_only: bool = False, + bookmarked: bool = False, sort: str = "date", page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100), ): - return get_announcements(region, status, house_type, matched_only, sort, page, size) + return get_announcements(region, status, house_type, matched_only, bookmarked, sort, page, size) @app.get("/api/realestate/announcements/{ann_id}") @@ -102,6 +104,14 @@ def api_announcement_update(ann_id: int, body: AnnouncementUpdate): return updated +@app.patch("/api/realestate/announcements/{ann_id}/bookmark") +def api_announcement_bookmark(ann_id: int): + result = toggle_bookmark(ann_id) + if result is None: + raise HTTPException(status_code=404, detail="Announcement not found") + return result + + @app.delete("/api/realestate/announcements/{ann_id}") def api_announcement_delete(ann_id: int): if not delete_announcement(ann_id):