8개 lab의 _conn() 함수에 표준 동시성 패턴 통일:
- timeout=120.0 (connection 획득)
- PRAGMA journal_mode=WAL (reader/writer 분리)
- PRAGMA busy_timeout=120000 (트랜잭션 충돌 시 120초 대기)
stock-lab/screener/router.py 의 검증된 패턴(d9b6122) 을 lotto, stock-lab(메인),
music-lab, blog-lab, realestate-lab, agent-office, personal, travel-proxy 로 확산.
기존 'database is locked' 오류 윈도우를 흡수.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
844 lines
36 KiB
Python
844 lines
36 KiB
Python
# realestate-lab/app/db.py
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import logging
|
|
from typing import Dict, Any, List, Optional
|
|
from datetime import date
|
|
|
|
logger = logging.getLogger("realestate-lab")
|
|
|
|
DB_PATH = os.getenv("REALESTATE_DB_PATH", "/app/data/realestate.db")
|
|
|
|
|
|
def _conn():
|
|
c = sqlite3.connect(DB_PATH, timeout=120.0)
|
|
c.row_factory = sqlite3.Row
|
|
c.execute("PRAGMA journal_mode=WAL;")
|
|
c.execute("PRAGMA busy_timeout=120000;")
|
|
c.execute("PRAGMA foreign_keys=ON;")
|
|
return c
|
|
|
|
|
|
def init_db():
|
|
with _conn() as conn:
|
|
# ── announcements ────────────────────────────────────────────────
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS announcements (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
house_manage_no TEXT NOT NULL,
|
|
pblanc_no TEXT NOT NULL,
|
|
house_nm TEXT,
|
|
house_secd TEXT,
|
|
house_dtl_secd TEXT,
|
|
rent_secd TEXT,
|
|
region_code TEXT,
|
|
region_name TEXT,
|
|
address TEXT,
|
|
total_units INTEGER,
|
|
rcrit_date TEXT,
|
|
receipt_start TEXT,
|
|
receipt_end TEXT,
|
|
spsply_start TEXT,
|
|
spsply_end TEXT,
|
|
gnrl_rank1_start TEXT,
|
|
gnrl_rank1_end TEXT,
|
|
winner_date TEXT,
|
|
contract_start TEXT,
|
|
contract_end TEXT,
|
|
homepage_url TEXT,
|
|
pblanc_url TEXT,
|
|
constructor TEXT,
|
|
developer TEXT,
|
|
move_in_month TEXT,
|
|
is_speculative_area TEXT,
|
|
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')),
|
|
UNIQUE(house_manage_no, pblanc_no)
|
|
);
|
|
""")
|
|
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);")
|
|
|
|
# ── 마이그레이션: district 컬럼 + 인덱스 추가 ──
|
|
try:
|
|
conn.execute("SELECT district FROM announcements LIMIT 1")
|
|
except Exception:
|
|
conn.execute("ALTER TABLE announcements ADD COLUMN district TEXT")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_district ON announcements(district);")
|
|
|
|
# ── 마이그레이션: 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 (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
house_manage_no TEXT NOT NULL,
|
|
pblanc_no TEXT NOT NULL,
|
|
model_no TEXT,
|
|
house_ty TEXT,
|
|
supply_area REAL,
|
|
general_units INTEGER DEFAULT 0,
|
|
special_units INTEGER DEFAULT 0,
|
|
multi_child_units INTEGER DEFAULT 0,
|
|
newlywed_units INTEGER DEFAULT 0,
|
|
first_life_units INTEGER DEFAULT 0,
|
|
old_parent_units INTEGER DEFAULT 0,
|
|
institution_units INTEGER DEFAULT 0,
|
|
youth_units INTEGER DEFAULT 0,
|
|
newborn_units INTEGER DEFAULT 0,
|
|
top_amount INTEGER,
|
|
UNIQUE(house_manage_no, pblanc_no, model_no)
|
|
);
|
|
""")
|
|
|
|
# ── user_profile ─────────────────────────────────────────────────
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS user_profile (
|
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
|
name TEXT,
|
|
age INTEGER,
|
|
is_homeless INTEGER,
|
|
is_householder INTEGER,
|
|
subscription_months INTEGER,
|
|
subscription_amount INTEGER,
|
|
family_members INTEGER,
|
|
has_dependents INTEGER,
|
|
children_count INTEGER DEFAULT 0,
|
|
is_newlywed INTEGER,
|
|
marriage_months INTEGER,
|
|
has_newborn INTEGER,
|
|
is_first_home INTEGER,
|
|
income_level TEXT,
|
|
preferred_regions TEXT NOT NULL DEFAULT '[]',
|
|
preferred_types TEXT NOT NULL DEFAULT '[]',
|
|
min_area REAL,
|
|
max_area REAL,
|
|
max_price INTEGER,
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
);
|
|
""")
|
|
|
|
# ── 마이그레이션: user_profile 신규 3컬럼 ──
|
|
for col, ddl in (
|
|
("preferred_districts", "ALTER TABLE user_profile ADD COLUMN preferred_districts TEXT NOT NULL DEFAULT '{}'"),
|
|
("min_match_score", "ALTER TABLE user_profile ADD COLUMN min_match_score INTEGER NOT NULL DEFAULT 70"),
|
|
("notify_enabled", "ALTER TABLE user_profile ADD COLUMN notify_enabled INTEGER NOT NULL DEFAULT 1"),
|
|
):
|
|
try:
|
|
conn.execute(f"SELECT {col} FROM user_profile LIMIT 1")
|
|
except Exception:
|
|
conn.execute(ddl)
|
|
|
|
# ── match_results ────────────────────────────────────────────────
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS match_results (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
announcement_id INTEGER NOT NULL REFERENCES announcements(id) ON DELETE CASCADE,
|
|
model_id INTEGER,
|
|
match_score INTEGER NOT NULL DEFAULT 0,
|
|
match_reasons TEXT NOT NULL DEFAULT '[]',
|
|
eligible_types TEXT NOT NULL DEFAULT '[]',
|
|
is_new INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
UNIQUE(announcement_id, model_id)
|
|
);
|
|
""")
|
|
|
|
# ── 마이그레이션: notified_at 컬럼 추가 ──
|
|
try:
|
|
conn.execute("SELECT notified_at FROM match_results LIMIT 1")
|
|
except Exception:
|
|
conn.execute("ALTER TABLE match_results ADD COLUMN notified_at TEXT")
|
|
|
|
# ── 마이그레이션: score_breakdown 컬럼 추가 ──
|
|
try:
|
|
conn.execute("SELECT score_breakdown FROM match_results LIMIT 1")
|
|
except Exception:
|
|
conn.execute("ALTER TABLE match_results ADD COLUMN score_breakdown TEXT")
|
|
|
|
# ── collect_log ──────────────────────────────────────────────────
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS collect_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
collected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
new_count INTEGER NOT NULL DEFAULT 0,
|
|
total_count INTEGER NOT NULL DEFAULT 0,
|
|
error TEXT
|
|
);
|
|
""")
|
|
|
|
|
|
# ── 상태 자동 계산 ───────────────────────────────────────────────────────────
|
|
|
|
def compute_status(receipt_start: str, receipt_end: str, winner_date: str) -> str:
|
|
today = date.today().isoformat()
|
|
if receipt_start and today < receipt_start:
|
|
return "청약예정"
|
|
if receipt_start and receipt_end and receipt_start <= today <= receipt_end:
|
|
return "청약중"
|
|
if receipt_end and winner_date and receipt_end < today <= winner_date:
|
|
return "결과발표"
|
|
if winner_date and today > winner_date:
|
|
return "완료"
|
|
return "청약예정"
|
|
|
|
|
|
# ── announcements CRUD ───────────────────────────────────────────────────────
|
|
|
|
def _ann_row_to_dict(r) -> Dict[str, Any]:
|
|
return {c: r[c] for c in r.keys()}
|
|
|
|
|
|
def upsert_announcement(data: Dict[str, Any]) -> tuple:
|
|
"""공고 upsert — house_manage_no + pblanc_no 기준. Returns (dict, is_new: bool)."""
|
|
data.setdefault("district", None) # 수동 등록 등에서 누락 시 안전 처리
|
|
status = compute_status(
|
|
data.get("receipt_start", ""),
|
|
data.get("receipt_end", ""),
|
|
data.get("winner_date", ""),
|
|
)
|
|
with _conn() as conn:
|
|
exists = conn.execute(
|
|
"SELECT 1 FROM announcements WHERE house_manage_no = ? AND pblanc_no = ?",
|
|
(data["house_manage_no"], data["pblanc_no"]),
|
|
).fetchone()
|
|
is_new = exists is None
|
|
conn.execute("""
|
|
INSERT INTO announcements (
|
|
house_manage_no, pblanc_no, house_nm, house_secd, house_dtl_secd,
|
|
rent_secd, region_code, region_name, district, address, total_units,
|
|
rcrit_date, receipt_start, receipt_end, spsply_start, spsply_end,
|
|
gnrl_rank1_start, gnrl_rank1_end, winner_date, contract_start,
|
|
contract_end, homepage_url, pblanc_url, constructor, developer,
|
|
move_in_month, is_speculative_area, is_price_cap, contact,
|
|
status, source
|
|
) VALUES (
|
|
:house_manage_no, :pblanc_no, :house_nm, :house_secd, :house_dtl_secd,
|
|
:rent_secd, :region_code, :region_name, :district, :address, :total_units,
|
|
:rcrit_date, :receipt_start, :receipt_end, :spsply_start, :spsply_end,
|
|
:gnrl_rank1_start, :gnrl_rank1_end, :winner_date, :contract_start,
|
|
:contract_end, :homepage_url, :pblanc_url, :constructor, :developer,
|
|
:move_in_month, :is_speculative_area, :is_price_cap, :contact,
|
|
:status, :source
|
|
)
|
|
ON CONFLICT(house_manage_no, pblanc_no) DO UPDATE SET
|
|
house_nm=excluded.house_nm,
|
|
house_secd=excluded.house_secd,
|
|
house_dtl_secd=excluded.house_dtl_secd,
|
|
rent_secd=excluded.rent_secd,
|
|
region_code=excluded.region_code,
|
|
region_name=excluded.region_name,
|
|
district=excluded.district,
|
|
address=excluded.address,
|
|
total_units=excluded.total_units,
|
|
rcrit_date=excluded.rcrit_date,
|
|
receipt_start=excluded.receipt_start,
|
|
receipt_end=excluded.receipt_end,
|
|
spsply_start=excluded.spsply_start,
|
|
spsply_end=excluded.spsply_end,
|
|
gnrl_rank1_start=excluded.gnrl_rank1_start,
|
|
gnrl_rank1_end=excluded.gnrl_rank1_end,
|
|
winner_date=excluded.winner_date,
|
|
contract_start=excluded.contract_start,
|
|
contract_end=excluded.contract_end,
|
|
homepage_url=excluded.homepage_url,
|
|
pblanc_url=excluded.pblanc_url,
|
|
constructor=excluded.constructor,
|
|
developer=excluded.developer,
|
|
move_in_month=excluded.move_in_month,
|
|
is_speculative_area=excluded.is_speculative_area,
|
|
is_price_cap=excluded.is_price_cap,
|
|
contact=excluded.contact,
|
|
status=excluded.status,
|
|
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
""", {**data, "status": status})
|
|
row = conn.execute(
|
|
"SELECT * FROM announcements WHERE house_manage_no = ? AND pblanc_no = ?",
|
|
(data["house_manage_no"], data["pblanc_no"]),
|
|
).fetchone()
|
|
return _ann_row_to_dict(row), is_new
|
|
|
|
|
|
def _enrich_items(conn, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""공고 목록에 모델 기반 가격 범위 + 매칭 점수를 추가한다."""
|
|
for item in items:
|
|
ann_id = item.get("id")
|
|
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"]
|
|
# 매칭 점수
|
|
if ann_id:
|
|
match_row = conn.execute(
|
|
"SELECT match_score, match_reasons, eligible_types, score_breakdown FROM match_results WHERE announcement_id = ?",
|
|
(ann_id,),
|
|
).fetchone()
|
|
if match_row:
|
|
item["match_score"] = match_row["match_score"]
|
|
item["match_reasons"] = json.loads(match_row["match_reasons"]) if match_row["match_reasons"] else []
|
|
item["eligible_types"] = json.loads(match_row["eligible_types"]) if match_row["eligible_types"] else []
|
|
item["score_breakdown"] = json.loads(match_row["score_breakdown"]) if match_row["score_breakdown"] else None
|
|
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 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)")
|
|
|
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
|
|
order_map = {"date": "a.rcrit_date DESC", "score": "a.id DESC", "price": "a.id ASC"}
|
|
order = order_map.get(sort, "a.rcrit_date DESC")
|
|
if matched_only and sort == "score":
|
|
order = "(SELECT MAX(match_score) FROM match_results WHERE announcement_id = a.id) DESC"
|
|
|
|
offset = (page - 1) * size
|
|
|
|
with _conn() as conn:
|
|
total = conn.execute(
|
|
f"SELECT COUNT(*) FROM announcements a {where}", params
|
|
).fetchone()[0]
|
|
rows = conn.execute(
|
|
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_items(conn, items)
|
|
return {
|
|
"items": items,
|
|
"total": total,
|
|
"page": page,
|
|
"size": size,
|
|
}
|
|
|
|
|
|
def get_announcement(ann_id: int) -> Optional[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
row = conn.execute("SELECT * FROM announcements WHERE id = ?", (ann_id,)).fetchone()
|
|
if not row:
|
|
return None
|
|
ann = _ann_row_to_dict(row)
|
|
models = conn.execute(
|
|
"SELECT * FROM announcement_models WHERE house_manage_no = ? AND pblanc_no = ?",
|
|
(ann["house_manage_no"], ann["pblanc_no"]),
|
|
).fetchall()
|
|
ann["models"] = [dict(m) for m in models]
|
|
return ann
|
|
|
|
|
|
def create_announcement(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""수동 공고 등록 (house_manage_no 자동 생성)."""
|
|
import uuid
|
|
data["house_manage_no"] = data.get("house_manage_no", f"MANUAL-{uuid.uuid4().hex[:8]}")
|
|
data["pblanc_no"] = data.get("pblanc_no", "00")
|
|
data["source"] = "manual"
|
|
result, _ = upsert_announcement(data)
|
|
return result
|
|
|
|
|
|
ANNOUNCEMENT_COLUMNS = {
|
|
"house_nm", "house_secd", "house_dtl_secd", "rent_secd",
|
|
"region_code", "region_name", "district", "address", "total_units",
|
|
"rcrit_date", "receipt_start", "receipt_end", "spsply_start", "spsply_end",
|
|
"gnrl_rank1_start", "gnrl_rank1_end", "winner_date",
|
|
"contract_start", "contract_end", "homepage_url", "pblanc_url",
|
|
"constructor", "developer", "move_in_month",
|
|
"is_speculative_area", "is_price_cap", "contact",
|
|
}
|
|
|
|
|
|
def update_announcement(ann_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
fields = {k: v for k, v in data.items() if v is not None and k in ANNOUNCEMENT_COLUMNS}
|
|
if not fields:
|
|
return get_announcement(ann_id)
|
|
|
|
# 날짜 변경 시 status 재계산
|
|
with _conn() as conn:
|
|
row = conn.execute("SELECT * FROM announcements WHERE id = ?", (ann_id,)).fetchone()
|
|
if not row:
|
|
return None
|
|
current = _ann_row_to_dict(row)
|
|
merged = {**current, **fields}
|
|
status = compute_status(
|
|
merged.get("receipt_start", ""),
|
|
merged.get("receipt_end", ""),
|
|
merged.get("winner_date", ""),
|
|
)
|
|
fields["status"] = status
|
|
|
|
set_clauses = ", ".join(f"{k} = ?" for k in fields)
|
|
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
|
conn.execute(
|
|
f"UPDATE announcements SET {set_clauses} WHERE id = ?",
|
|
list(fields.values()) + [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:
|
|
with _conn() as conn:
|
|
# match_results는 FK CASCADE로 자동 삭제
|
|
cur = conn.execute("DELETE FROM announcements WHERE id = ?", (ann_id,))
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def delete_closed_announcements() -> int:
|
|
"""status='완료' 공고 일괄 삭제. 삭제된 건수 반환."""
|
|
with _conn() as conn:
|
|
cur = conn.execute("DELETE FROM announcements WHERE status = '완료'")
|
|
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:
|
|
rows = conn.execute(
|
|
"SELECT id, status, receipt_start, receipt_end, winner_date FROM announcements "
|
|
"WHERE status != '완료' AND (receipt_start IS NOT NULL OR receipt_end IS NOT NULL OR winner_date IS NOT NULL)"
|
|
).fetchall()
|
|
for r in rows:
|
|
new_status = compute_status(r["receipt_start"], r["receipt_end"], r["winner_date"])
|
|
if new_status != r["status"]: # only update if status actually changed
|
|
conn.execute(
|
|
"UPDATE announcements SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
|
(new_status, r["id"]),
|
|
)
|
|
|
|
|
|
# ── announcement_models CRUD ─────────────────────────────────────────────────
|
|
|
|
def upsert_model(data: Dict[str, Any]):
|
|
with _conn() as conn:
|
|
conn.execute("""
|
|
INSERT INTO announcement_models (
|
|
house_manage_no, pblanc_no, model_no, house_ty, supply_area,
|
|
general_units, special_units, multi_child_units, newlywed_units,
|
|
first_life_units, old_parent_units, institution_units,
|
|
youth_units, newborn_units, top_amount
|
|
) VALUES (
|
|
:house_manage_no, :pblanc_no, :model_no, :house_ty, :supply_area,
|
|
:general_units, :special_units, :multi_child_units, :newlywed_units,
|
|
:first_life_units, :old_parent_units, :institution_units,
|
|
:youth_units, :newborn_units, :top_amount
|
|
)
|
|
ON CONFLICT(house_manage_no, pblanc_no, model_no) DO UPDATE SET
|
|
house_ty=excluded.house_ty,
|
|
supply_area=excluded.supply_area,
|
|
general_units=excluded.general_units,
|
|
special_units=excluded.special_units,
|
|
multi_child_units=excluded.multi_child_units,
|
|
newlywed_units=excluded.newlywed_units,
|
|
first_life_units=excluded.first_life_units,
|
|
old_parent_units=excluded.old_parent_units,
|
|
institution_units=excluded.institution_units,
|
|
youth_units=excluded.youth_units,
|
|
newborn_units=excluded.newborn_units,
|
|
top_amount=excluded.top_amount
|
|
""", data)
|
|
|
|
|
|
# ── 청약 가점 계산 ───────────────────────────────────────────────────────────
|
|
|
|
def calculate_subscription_points(profile: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""청약 가점제 점수 계산 (총 84점 만점).
|
|
|
|
1. 무주택기간 (0~32점): 만 30세부터 기산, 연 2점
|
|
2. 부양가족 수 (0~35점): 인당 5점, 6명+ 만점
|
|
3. 청약통장 가입기간 (0~17점): 6개월 미만 1점 ~ 15년+ 17점
|
|
"""
|
|
result = {
|
|
"homeless_duration": {"score": 0, "max": 32, "detail": ""},
|
|
"dependents": {"score": 0, "max": 35, "detail": ""},
|
|
"subscription_period": {"score": 0, "max": 17, "detail": ""},
|
|
"total": 0,
|
|
"max_total": 84,
|
|
}
|
|
|
|
if not profile:
|
|
return result
|
|
|
|
# 1. 무주택기간 (만 30세부터 기산, 연 2점, 최대 32점)
|
|
age = profile.get("age") or 0
|
|
is_homeless = profile.get("is_homeless", False)
|
|
if is_homeless and age >= 30:
|
|
homeless_years = age - 30
|
|
score = min(homeless_years * 2, 32)
|
|
# 1년 미만도 2점
|
|
if homeless_years == 0:
|
|
score = 2
|
|
result["homeless_duration"]["score"] = score
|
|
result["homeless_duration"]["detail"] = f"만 {age}세, 무주택 약 {homeless_years}년"
|
|
elif is_homeless and age < 30:
|
|
result["homeless_duration"]["score"] = 0
|
|
result["homeless_duration"]["detail"] = f"만 {age}세 (30세 미만, 기간 미산정)"
|
|
else:
|
|
result["homeless_duration"]["detail"] = "유주택자"
|
|
|
|
# 2. 부양가족 수 (인당 5점, 최대 35점)
|
|
family_members = profile.get("family_members") or 0
|
|
dependents = max(family_members - 1, 0) # 본인 제외
|
|
dep_score = min(dependents * 5, 35)
|
|
result["dependents"]["score"] = dep_score
|
|
result["dependents"]["detail"] = f"{dependents}명" if dependents > 0 else "0명 (본인만)"
|
|
|
|
# 3. 청약통장 가입기간 (6개월 미만 1점, 이후 1년마다 +1점, 최대 17점)
|
|
months = profile.get("subscription_months") or 0
|
|
if months <= 0:
|
|
sub_score = 0
|
|
sub_detail = "미가입"
|
|
elif months < 6:
|
|
sub_score = 1
|
|
sub_detail = f"{months}개월 (6개월 미만)"
|
|
else:
|
|
years = months / 12
|
|
# 6개월~1년 = 2점, 1~2년 = 3점, ..., 14~15년 = 16점, 15년+ = 17점
|
|
sub_score = min(int(years) + 2, 17)
|
|
if years < 1:
|
|
sub_score = 2
|
|
if years >= 1:
|
|
y = int(years)
|
|
sub_detail = f"{y}년 {months - y*12}개월"
|
|
else:
|
|
sub_detail = f"{months}개월"
|
|
result["subscription_period"]["score"] = sub_score
|
|
result["subscription_period"]["detail"] = sub_detail
|
|
|
|
result["total"] = (
|
|
result["homeless_duration"]["score"]
|
|
+ result["dependents"]["score"]
|
|
+ result["subscription_period"]["score"]
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
# ── user_profile CRUD ────────────────────────────────────────────────────────
|
|
|
|
def _profile_row_to_dict(r) -> Dict[str, Any]:
|
|
d = {}
|
|
for c in r.keys():
|
|
val = r[c]
|
|
if c in ("is_homeless", "is_householder", "has_dependents", "is_newlywed",
|
|
"has_newborn", "is_first_home", "notify_enabled"):
|
|
d[c] = bool(val) if val is not None else None
|
|
elif c in ("preferred_regions", "preferred_types"):
|
|
d[c] = json.loads(val) if val else []
|
|
elif c == "preferred_districts":
|
|
d[c] = json.loads(val) if val else {}
|
|
else:
|
|
d[c] = val
|
|
return d
|
|
|
|
|
|
def get_profile() -> Optional[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
r = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
|
|
if not r:
|
|
return None
|
|
profile = _profile_row_to_dict(r)
|
|
profile["subscription_points"] = calculate_subscription_points(profile)
|
|
return profile
|
|
|
|
|
|
PROFILE_COLUMNS = {
|
|
"name", "age", "is_homeless", "is_householder",
|
|
"subscription_months", "subscription_amount", "family_members",
|
|
"has_dependents", "children_count", "is_newlywed", "marriage_months",
|
|
"has_newborn", "is_first_home", "income_level",
|
|
"preferred_regions", "preferred_types", "preferred_districts",
|
|
"min_area", "max_area", "max_price",
|
|
"min_match_score", "notify_enabled",
|
|
}
|
|
|
|
|
|
def upsert_profile(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
updates = {}
|
|
for k, v in data.items():
|
|
if v is None or k not in PROFILE_COLUMNS:
|
|
continue
|
|
if isinstance(v, bool):
|
|
updates[k] = 1 if v else 0
|
|
elif isinstance(v, (list, dict)):
|
|
updates[k] = json.dumps(v)
|
|
else:
|
|
updates[k] = v
|
|
|
|
with _conn() as conn:
|
|
existing = conn.execute("SELECT id FROM user_profile WHERE id = 1").fetchone()
|
|
if existing:
|
|
if updates:
|
|
set_clauses = ", ".join(f"{k} = ?" for k in updates)
|
|
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
|
conn.execute(
|
|
f"UPDATE user_profile SET {set_clauses} WHERE id = 1",
|
|
list(updates.values()),
|
|
)
|
|
else:
|
|
cols = ["id"] + list(updates.keys())
|
|
vals = [1] + list(updates.values())
|
|
placeholders = ", ".join("?" for _ in vals)
|
|
conn.execute(
|
|
f"INSERT INTO user_profile ({', '.join(cols)}) VALUES ({placeholders})",
|
|
vals,
|
|
)
|
|
row = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
|
|
profile = _profile_row_to_dict(row)
|
|
profile["subscription_points"] = calculate_subscription_points(profile)
|
|
return profile
|
|
|
|
|
|
# ── match_results CRUD ───────────────────────────────────────────────────────
|
|
|
|
def save_match_result(data: Dict[str, Any]):
|
|
with _conn() as conn:
|
|
conn.execute("""
|
|
INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new)
|
|
VALUES (:announcement_id, :model_id, :match_score, :match_reasons, :eligible_types, 1)
|
|
ON CONFLICT(announcement_id, model_id) DO UPDATE SET
|
|
match_score=excluded.match_score,
|
|
match_reasons=excluded.match_reasons,
|
|
eligible_types=excluded.eligible_types
|
|
""", {
|
|
**data,
|
|
"match_reasons": json.dumps(data.get("match_reasons", [])),
|
|
"eligible_types": json.dumps(data.get("eligible_types", [])),
|
|
})
|
|
|
|
|
|
def get_matches(page: int = 1, size: int = 20) -> Dict[str, Any]:
|
|
offset = (page - 1) * size
|
|
with _conn() as conn:
|
|
# 프로필 가점 계산
|
|
profile_row = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
|
|
points = None
|
|
if profile_row:
|
|
profile = _profile_row_to_dict(profile_row)
|
|
points = calculate_subscription_points(profile)
|
|
|
|
total = conn.execute("SELECT COUNT(*) FROM match_results").fetchone()[0]
|
|
rows = conn.execute("""
|
|
SELECT m.*, a.house_nm, a.region_name, a.address, a.district, a.status as ann_status,
|
|
a.receipt_start, a.receipt_end, a.winner_date, a.pblanc_url,
|
|
a.house_secd, a.is_speculative_area
|
|
FROM match_results m
|
|
JOIN announcements a ON a.id = m.announcement_id
|
|
ORDER BY m.is_new DESC, m.match_score DESC
|
|
LIMIT ? OFFSET ?
|
|
""", (size, offset)).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 []
|
|
d["score_breakdown"] = json.loads(d["score_breakdown"]) if d.get("score_breakdown") else None
|
|
items.append(d)
|
|
return {
|
|
"items": items,
|
|
"total": total,
|
|
"page": page,
|
|
"size": size,
|
|
"my_points": points,
|
|
}
|
|
|
|
|
|
def mark_match_read(match_id: int) -> bool:
|
|
with _conn() as conn:
|
|
cur = conn.execute("UPDATE match_results SET is_new = 0 WHERE id = ?", (match_id,))
|
|
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.status,
|
|
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})",
|
|
tuple(match_ids),
|
|
)
|
|
|
|
|
|
# ── collect_log CRUD ─────────────────────────────────────────────────────────
|
|
|
|
def save_collect_log(new_count: int, total_count: int, error: str = None):
|
|
with _conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO collect_log (new_count, total_count, error) VALUES (?, ?, ?)",
|
|
(new_count, total_count, error),
|
|
)
|
|
|
|
|
|
def get_last_collect_log() -> Optional[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
r = conn.execute("SELECT * FROM collect_log ORDER BY id DESC LIMIT 1").fetchone()
|
|
return dict(r) if r else None
|
|
|
|
|
|
# ── 대시보드 ─────────────────────────────────────────────────────────────────
|
|
|
|
def get_dashboard() -> Dict[str, Any]:
|
|
with _conn() as conn:
|
|
active = conn.execute(
|
|
"SELECT COUNT(*) FROM announcements WHERE status IN ('청약예정', '청약중')"
|
|
).fetchone()[0]
|
|
new_matches = conn.execute(
|
|
"SELECT COUNT(*) FROM match_results WHERE is_new = 1"
|
|
).fetchone()[0]
|
|
bookmarked_count = conn.execute(
|
|
"SELECT COUNT(*) FROM announcements WHERE is_bookmarked = 1"
|
|
).fetchone()[0]
|
|
profile_row = conn.execute(
|
|
"SELECT min_match_score FROM user_profile WHERE id = 1"
|
|
).fetchone()
|
|
min_score = profile_row["min_match_score"] if profile_row else 70
|
|
pass_count = conn.execute(
|
|
"SELECT COUNT(*) FROM match_results WHERE match_score >= ?", (min_score,)
|
|
).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 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_items(conn, bookmarked_items)
|
|
|
|
return {
|
|
"active_count": active,
|
|
"new_match_count": new_matches,
|
|
"pass_count": pass_count,
|
|
"bookmarked_count": bookmarked_count,
|
|
"upcoming_schedules": schedules,
|
|
"bookmarked": bookmarked_items,
|
|
}
|