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:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user