feat(realestate-lab): 즐겨찾기 + 가격 표시 + 일정 없는 공고 필터링

- announcements 테이블에 is_bookmarked 컬럼 추가 (마이그레이션 포함)
- PATCH /announcements/{id}/bookmark 토글 API 추가
- 공고 목록에 모델 기반 가격 범위(min_price, max_price_display) 포함
- 대시보드에 즐겨찾기 목록 + 개별 이벤트 일정 형식 반환
- 지역 검색을 LIKE 부분 매칭으로 변경
- 수집 시 일정 정보 없는 공고 건너뛰기

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:38:57 +09:00
parent 011eac7682
commit 243c101981
3 changed files with 108 additions and 9 deletions

View File

@@ -145,6 +145,13 @@ def collect_all() -> Dict[str, Any]:
for raw in detail_rows: for raw in detail_rows:
try: try:
parsed = _parse_apt_detail(raw) 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) _, is_new = upsert_announcement(parsed)
total_count += 1 total_count += 1
if is_new: if is_new:

View File

@@ -53,6 +53,7 @@ def init_db():
is_price_cap TEXT, is_price_cap TEXT,
contact TEXT, contact TEXT,
status TEXT NOT NULL DEFAULT '청약예정', status TEXT NOT NULL DEFAULT '청약예정',
is_bookmarked INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'manual', source TEXT NOT NULL DEFAULT 'manual',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), 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')), 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_status ON announcements(status);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_region ON announcements(region_name);") 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 ────────────────────────────────────────── # ── announcement_models ──────────────────────────────────────────
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS announcement_models ( 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 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( def get_announcements(
region: str = None, region: str = None,
status: str = None, status: str = None,
house_type: str = None, house_type: str = None,
matched_only: bool = False, matched_only: bool = False,
bookmarked: bool = False,
sort: str = "date", sort: str = "date",
page: int = 1, page: int = 1,
size: int = 20, size: int = 20,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
conditions, params = [], [] conditions, params = [], []
if region: if region:
conditions.append("a.region_name = ?") conditions.append("a.region_name LIKE ?")
params.append(region) params.append(f"%{region}%")
if status: if status:
conditions.append("a.status = ?") conditions.append("a.status = ?")
params.append(status) params.append(status)
if house_type: if house_type:
conditions.append("a.house_secd = ?") conditions.append("a.house_secd = ?")
params.append(house_type) params.append(house_type)
if bookmarked:
conditions.append("a.is_bookmarked = 1")
if matched_only: if matched_only:
conditions.append("a.id IN (SELECT announcement_id FROM match_results)") 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 ?", f"SELECT a.* FROM announcements a {where} ORDER BY {order} LIMIT ? OFFSET ?",
params + [size, offset], params + [size, offset],
).fetchall() ).fetchall()
items = [_ann_row_to_dict(r) for r in rows]
items = _enrich_with_price(conn, items)
return { return {
"items": [_ann_row_to_dict(r) for r in rows], "items": items,
"total": total, "total": total,
"page": page, "page": page,
"size": size, "size": size,
@@ -339,6 +368,20 @@ def update_announcement(ann_id: int, data: Dict[str, Any]) -> Optional[Dict[str,
return get_announcement(ann_id) 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: def delete_announcement(ann_id: int) -> bool:
with _conn() as conn: with _conn() as conn:
# match_results는 FK CASCADE로 자동 삭제 # match_results는 FK CASCADE로 자동 삭제
@@ -532,15 +575,54 @@ def get_dashboard() -> Dict[str, Any]:
new_matches = conn.execute( new_matches = conn.execute(
"SELECT COUNT(*) FROM match_results WHERE is_new = 1" "SELECT COUNT(*) FROM match_results WHERE is_new = 1"
).fetchone()[0] ).fetchone()[0]
upcoming = conn.execute(""" bookmarked_count = conn.execute(
SELECT id, house_nm, receipt_start, receipt_end, status "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 FROM announcements
WHERE status IN ('청약예정', '청약중') WHERE status IN ('청약예정', '청약중')
ORDER BY receipt_start ASC ORDER BY receipt_start ASC
LIMIT 5 LIMIT 20
""").fetchall() """).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 { return {
"active_count": active, "active_count": active,
"new_match_count": new_matches, "new_match_count": new_matches,
"upcoming": [dict(r) for r in upcoming], "bookmarked_count": bookmarked_count,
"upcoming_schedules": schedules,
"bookmarked": bookmarked_items,
} }

View File

@@ -8,7 +8,8 @@ from apscheduler.schedulers.background import BackgroundScheduler
from .db import ( from .db import (
init_db, get_announcements, get_announcement, create_announcement, 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_profile, upsert_profile, get_matches, mark_match_read,
get_last_collect_log, get_dashboard, get_last_collect_log, get_dashboard,
) )
@@ -74,11 +75,12 @@ def api_announcements(
status: str = None, status: str = None,
house_type: str = None, house_type: str = None,
matched_only: bool = False, matched_only: bool = False,
bookmarked: bool = False,
sort: str = "date", sort: str = "date",
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100), 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}") @app.get("/api/realestate/announcements/{ann_id}")
@@ -102,6 +104,14 @@ def api_announcement_update(ann_id: int, body: AnnouncementUpdate):
return updated 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}") @app.delete("/api/realestate/announcements/{ann_id}")
def api_announcement_delete(ann_id: int): def api_announcement_delete(ann_id: int):
if not delete_announcement(ann_id): if not delete_announcement(ann_id):