diff --git a/realestate-lab/app/db.py b/realestate-lab/app/db.py index 737818a..21da0d9 100644 --- a/realestate-lab/app/db.py +++ b/realestate-lab/app/db.py @@ -433,6 +433,24 @@ def delete_closed_announcements() -> int: return cur.rowcount +def delete_old_completed_announcements(grace_days: int = 90) -> int: + """winner_date + grace_days 경과한 status='완료' 공고를 삭제. + winner_date가 NULL인 행은 안전하게 보존(수동 검토 대상). + match_results는 FK CASCADE로 자동 삭제. 삭제된 건수 반환. + """ + with _conn() as conn: + cur = conn.execute( + """ + DELETE FROM announcements + WHERE status = '완료' + AND winner_date IS NOT NULL + AND date(winner_date) < date('now', ?) + """, + (f"-{grace_days} days",), + ) + return cur.rowcount + + def update_all_statuses(): """모든 진행중 공고의 status를 날짜 기반으로 재계산.""" with _conn() as conn: @@ -691,6 +709,42 @@ def mark_match_read(match_id: int) -> bool: return cur.rowcount > 0 +def get_unnotified_matches(min_score: int) -> List[Dict[str, Any]]: + """notified_at IS NULL AND match_score >= min_score 인 매칭과 공고 정보 조인 반환.""" + with _conn() as conn: + rows = conn.execute(""" + SELECT m.id, m.announcement_id, m.match_score, m.match_reasons, m.eligible_types, + a.house_nm, a.region_name, a.district, a.address, + a.receipt_start, a.receipt_end, a.winner_date, + a.house_secd, a.is_speculative_area, a.is_price_cap, a.pblanc_url + FROM match_results m + JOIN announcements a ON a.id = m.announcement_id + WHERE m.notified_at IS NULL + AND m.match_score >= ? + ORDER BY m.match_score DESC + """, (min_score,)).fetchall() + items = [] + for r in rows: + d = {c: r[c] for c in r.keys()} + d["match_reasons"] = json.loads(d["match_reasons"]) if d["match_reasons"] else [] + d["eligible_types"] = json.loads(d["eligible_types"]) if d["eligible_types"] else [] + items.append(d) + return items + + +def mark_matches_notified(match_ids: List[int]) -> None: + """주어진 match_results IDs의 notified_at을 현재 시각으로 일괄 업데이트.""" + if not match_ids: + return + placeholders = ",".join("?" for _ in match_ids) + with _conn() as conn: + conn.execute( + f"UPDATE match_results SET notified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') " + f"WHERE id IN ({placeholders})", + match_ids, + ) + + # ── collect_log CRUD ───────────────────────────────────────────────────────── def save_collect_log(new_count: int, total_count: int, error: str = None): diff --git a/realestate-lab/tests/test_db_functions.py b/realestate-lab/tests/test_db_functions.py new file mode 100644 index 0000000..9d46ce9 --- /dev/null +++ b/realestate-lab/tests/test_db_functions.py @@ -0,0 +1,86 @@ +import json +from datetime import date, timedelta +from app.db import _conn + + +def _seed_announcement(house_nm, status, winner_date=None, hmno="HM1", pno="P1"): + with _conn() as conn: + conn.execute(""" + INSERT INTO announcements (house_manage_no, pblanc_no, house_nm, status, winner_date, source) + VALUES (?, ?, ?, ?, ?, 'manual') + """, (hmno, pno, house_nm, status, winner_date)) + return conn.execute("SELECT id FROM announcements WHERE house_manage_no=?", (hmno,)).fetchone()["id"] + + +def test_delete_old_completed_removes_expired(): + from app.db import delete_old_completed_announcements + old = (date.today() - timedelta(days=100)).isoformat() + _seed_announcement("OldA", "완료", old, hmno="OLD", pno="1") + deleted = delete_old_completed_announcements(grace_days=90) + assert deleted == 1 + + +def test_delete_old_completed_keeps_recent(): + from app.db import delete_old_completed_announcements + recent = (date.today() - timedelta(days=30)).isoformat() + _seed_announcement("RecentA", "완료", recent, hmno="REC", pno="1") + deleted = delete_old_completed_announcements(grace_days=90) + assert deleted == 0 + + +def test_delete_old_completed_keeps_active(): + from app.db import delete_old_completed_announcements + old = (date.today() - timedelta(days=200)).isoformat() + _seed_announcement("ActiveA", "청약중", old, hmno="ACT", pno="1") + deleted = delete_old_completed_announcements(grace_days=90) + assert deleted == 0 + + +def test_delete_old_completed_keeps_null_winner_date(): + from app.db import delete_old_completed_announcements + _seed_announcement("NullA", "완료", None, hmno="NULL", pno="1") + deleted = delete_old_completed_announcements(grace_days=90) + assert deleted == 0 # winner_date NULL은 안전 보존 + + +def test_get_unnotified_matches_filters_by_score_and_null(): + from app.db import get_unnotified_matches + aid = _seed_announcement("MatchA", "청약중", hmno="MA", pno="1") + with _conn() as conn: + # 임계값 미만 + conn.execute(""" + INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new) + VALUES (?, NULL, 50, '[]', '[]', 1) + """, (aid,)) + # 임계값 통과 — 미알림 + conn.execute(""" + INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new) + VALUES (?, 1, 80, '[]', '[]', 1) + """, (aid,)) + # 임계값 통과 — 이미 알림됨 + conn.execute(""" + INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new, notified_at) + VALUES (?, 2, 90, '[]', '[]', 1, '2026-04-01T00:00:00.000Z') + """, (aid,)) + + matches = get_unnotified_matches(min_score=70) + assert len(matches) == 1 + assert matches[0]["match_score"] == 80 + assert matches[0]["house_nm"] == "MatchA" + + +def test_mark_matches_notified_sets_timestamp(): + from app.db import mark_matches_notified + aid = _seed_announcement("NotifyA", "청약중", hmno="NT", pno="1") + with _conn() as conn: + cur = conn.execute(""" + INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new) + VALUES (?, NULL, 80, '[]', '[]', 1) + """, (aid,)) + match_id = cur.lastrowid + + mark_matches_notified([match_id]) + + with _conn() as conn: + row = conn.execute("SELECT notified_at FROM match_results WHERE id = ?", (match_id,)).fetchone() + assert row["notified_at"] is not None