feat(realestate-db): add notify queue + 90-day grace cleanup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -433,6 +433,24 @@ def delete_closed_announcements() -> int:
|
|||||||
return cur.rowcount
|
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():
|
def update_all_statuses():
|
||||||
"""모든 진행중 공고의 status를 날짜 기반으로 재계산."""
|
"""모든 진행중 공고의 status를 날짜 기반으로 재계산."""
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
@@ -691,6 +709,42 @@ def mark_match_read(match_id: int) -> bool:
|
|||||||
return cur.rowcount > 0
|
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 ─────────────────────────────────────────────────────────
|
# ── collect_log CRUD ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def save_collect_log(new_count: int, total_count: int, error: str = None):
|
def save_collect_log(new_count: int, total_count: int, error: str = None):
|
||||||
|
|||||||
86
realestate-lab/tests/test_db_functions.py
Normal file
86
realestate-lab/tests/test_db_functions.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user