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