청약 관리 API 추가 (/api/subscription)
- subscription_items 테이블: 청약 목록 CRUD (GET/POST/PUT/DELETE) - subscription_profile 테이블: 내 청약 조건 프로필 싱글톤 (GET/PUT, upsert) - specialQuals JSON 배열, bool → int SQLite 변환 처리 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -213,6 +213,64 @@ def init_db() -> None:
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_realestate_status ON realestate_complexes(status);"
|
"CREATE INDEX IF NOT EXISTS idx_realestate_status ON realestate_complexes(status);"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── subscription_items 테이블 ──────────────────────────────────────────
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS subscription_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
complex_name TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
pyeong TEXT,
|
||||||
|
total_price INTEGER,
|
||||||
|
type TEXT,
|
||||||
|
special_type TEXT,
|
||||||
|
supply_type TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT '검토중',
|
||||||
|
min_score INTEGER,
|
||||||
|
max_income INTEGER,
|
||||||
|
homeless_required INTEGER,
|
||||||
|
subscription_start TEXT,
|
||||||
|
subscription_end TEXT,
|
||||||
|
contract_date TEXT,
|
||||||
|
interim_date TEXT,
|
||||||
|
balance_date TEXT,
|
||||||
|
result_date TEXT,
|
||||||
|
deposit_rate INTEGER DEFAULT 10,
|
||||||
|
interim_rate INTEGER DEFAULT 60,
|
||||||
|
balance_rate INTEGER DEFAULT 30,
|
||||||
|
loan_type TEXT,
|
||||||
|
loan_rate REAL,
|
||||||
|
memo TEXT NOT NULL DEFAULT '',
|
||||||
|
naver_url TEXT NOT NULL DEFAULT '',
|
||||||
|
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'))
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sub_items_created ON subscription_items(created_at DESC);"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── subscription_profile 테이블 (싱글톤 id=1) ──────────────────────────
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS subscription_profile (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
|
is_household_head INTEGER DEFAULT 1,
|
||||||
|
is_homeless INTEGER DEFAULT 1,
|
||||||
|
homeless_period INTEGER,
|
||||||
|
savings_months INTEGER,
|
||||||
|
savings_count INTEGER,
|
||||||
|
dependents INTEGER DEFAULT 0,
|
||||||
|
residency_area TEXT,
|
||||||
|
is_married INTEGER,
|
||||||
|
marriage_months INTEGER,
|
||||||
|
monthly_income INTEGER,
|
||||||
|
special_quals TEXT NOT NULL DEFAULT '[]'
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── todos CRUD ───────────────────────────────────────────────────────────────
|
# ── todos CRUD ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -828,3 +886,229 @@ def delete_complex(complex_id: int) -> bool:
|
|||||||
)
|
)
|
||||||
return cur.rowcount > 0
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── subscription_items CRUD ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SUB_ITEM_FIELD_MAP = {
|
||||||
|
"complexName": "complex_name",
|
||||||
|
"address": "address",
|
||||||
|
"pyeong": "pyeong",
|
||||||
|
"totalPrice": "total_price",
|
||||||
|
"type": "type",
|
||||||
|
"specialType": "special_type",
|
||||||
|
"supplyType": "supply_type",
|
||||||
|
"status": "status",
|
||||||
|
"minScore": "min_score",
|
||||||
|
"maxIncome": "max_income",
|
||||||
|
"homelessRequired": "homeless_required",
|
||||||
|
"subscriptionStart": "subscription_start",
|
||||||
|
"subscriptionEnd": "subscription_end",
|
||||||
|
"contractDate": "contract_date",
|
||||||
|
"interimDate": "interim_date",
|
||||||
|
"balanceDate": "balance_date",
|
||||||
|
"resultDate": "result_date",
|
||||||
|
"depositRate": "deposit_rate",
|
||||||
|
"interimRate": "interim_rate",
|
||||||
|
"balanceRate": "balance_rate",
|
||||||
|
"loanType": "loan_type",
|
||||||
|
"loanRate": "loan_rate",
|
||||||
|
"memo": "memo",
|
||||||
|
"naverUrl": "naver_url",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sub_item_row_to_dict(r) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": r["id"],
|
||||||
|
"complexName": r["complex_name"],
|
||||||
|
"address": r["address"],
|
||||||
|
"pyeong": r["pyeong"],
|
||||||
|
"totalPrice": r["total_price"],
|
||||||
|
"type": r["type"],
|
||||||
|
"specialType": r["special_type"],
|
||||||
|
"supplyType": r["supply_type"],
|
||||||
|
"status": r["status"],
|
||||||
|
"minScore": r["min_score"],
|
||||||
|
"maxIncome": r["max_income"],
|
||||||
|
"homelessRequired": r["homeless_required"],
|
||||||
|
"subscriptionStart": r["subscription_start"],
|
||||||
|
"subscriptionEnd": r["subscription_end"],
|
||||||
|
"contractDate": r["contract_date"],
|
||||||
|
"interimDate": r["interim_date"],
|
||||||
|
"balanceDate": r["balance_date"],
|
||||||
|
"resultDate": r["result_date"],
|
||||||
|
"depositRate": r["deposit_rate"],
|
||||||
|
"interimRate": r["interim_rate"],
|
||||||
|
"balanceRate": r["balance_rate"],
|
||||||
|
"loanType": r["loan_type"],
|
||||||
|
"loanRate": r["loan_rate"],
|
||||||
|
"memo": r["memo"],
|
||||||
|
"naverUrl": r["naver_url"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"updated_at": r["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_subscription_items() -> List[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM subscription_items ORDER BY created_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [_sub_item_row_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def create_subscription_item(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO subscription_items
|
||||||
|
(complex_name, address, pyeong, total_price, type, special_type, supply_type,
|
||||||
|
status, min_score, max_income, homeless_required,
|
||||||
|
subscription_start, subscription_end, contract_date, interim_date,
|
||||||
|
balance_date, result_date, deposit_rate, interim_rate, balance_rate,
|
||||||
|
loan_type, loan_rate, memo, naver_url)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
data["complexName"],
|
||||||
|
data.get("address", ""),
|
||||||
|
data.get("pyeong"),
|
||||||
|
data.get("totalPrice"),
|
||||||
|
data.get("type"),
|
||||||
|
data.get("specialType"),
|
||||||
|
data.get("supplyType"),
|
||||||
|
data.get("status", "검토중"),
|
||||||
|
data.get("minScore"),
|
||||||
|
data.get("maxIncome"),
|
||||||
|
data.get("homelessRequired"),
|
||||||
|
data.get("subscriptionStart"),
|
||||||
|
data.get("subscriptionEnd"),
|
||||||
|
data.get("contractDate"),
|
||||||
|
data.get("interimDate"),
|
||||||
|
data.get("balanceDate"),
|
||||||
|
data.get("resultDate"),
|
||||||
|
data.get("depositRate", 10),
|
||||||
|
data.get("interimRate", 60),
|
||||||
|
data.get("balanceRate", 30),
|
||||||
|
data.get("loanType"),
|
||||||
|
data.get("loanRate"),
|
||||||
|
data.get("memo", ""),
|
||||||
|
data.get("naverUrl", ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM subscription_items WHERE rowid = last_insert_rowid()"
|
||||||
|
).fetchone()
|
||||||
|
return _sub_item_row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def update_subscription_item(item_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
updates: Dict[str, Any] = {}
|
||||||
|
for camel, snake in _SUB_ITEM_FIELD_MAP.items():
|
||||||
|
if camel in data:
|
||||||
|
updates[snake] = data[camel]
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM subscription_items WHERE id = ?", (item_id,)
|
||||||
|
).fetchone()
|
||||||
|
return _sub_item_row_to_dict(row) if row else None
|
||||||
|
|
||||||
|
set_clauses = ", ".join(f"{k} = ?" for k in updates)
|
||||||
|
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
||||||
|
args = list(updates.values()) + [item_id]
|
||||||
|
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE subscription_items SET {set_clauses} WHERE id = ?", args
|
||||||
|
)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM subscription_items WHERE id = ?", (item_id,)
|
||||||
|
).fetchone()
|
||||||
|
return _sub_item_row_to_dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_subscription_item(item_id: int) -> bool:
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute("DELETE FROM subscription_items WHERE id = ?", (item_id,))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── subscription_profile CRUD (싱글톤) ────────────────────────────────────────
|
||||||
|
|
||||||
|
def _profile_row_to_dict(r) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"isHouseholdHead": bool(r["is_household_head"]) if r["is_household_head"] is not None else None,
|
||||||
|
"isHomeless": bool(r["is_homeless"]) if r["is_homeless"] is not None else None,
|
||||||
|
"homelessPeriod": r["homeless_period"],
|
||||||
|
"savingsMonths": r["savings_months"],
|
||||||
|
"savingsCount": r["savings_count"],
|
||||||
|
"dependents": r["dependents"],
|
||||||
|
"residencyArea": r["residency_area"],
|
||||||
|
"isMarried": bool(r["is_married"]) if r["is_married"] is not None else None,
|
||||||
|
"marriageMonths": r["marriage_months"],
|
||||||
|
"monthlyIncome": r["monthly_income"],
|
||||||
|
"specialQuals": json.loads(r["special_quals"]) if r["special_quals"] else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_subscription_profile() -> Optional[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT * FROM subscription_profile WHERE id = 1"
|
||||||
|
).fetchone()
|
||||||
|
return _profile_row_to_dict(r) if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_subscription_profile(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
field_map = {
|
||||||
|
"isHouseholdHead": "is_household_head",
|
||||||
|
"isHomeless": "is_homeless",
|
||||||
|
"homelessPeriod": "homeless_period",
|
||||||
|
"savingsMonths": "savings_months",
|
||||||
|
"savingsCount": "savings_count",
|
||||||
|
"dependents": "dependents",
|
||||||
|
"residencyArea": "residency_area",
|
||||||
|
"isMarried": "is_married",
|
||||||
|
"marriageMonths": "marriage_months",
|
||||||
|
"monthlyIncome": "monthly_income",
|
||||||
|
}
|
||||||
|
|
||||||
|
updates: Dict[str, Any] = {}
|
||||||
|
for camel, snake in field_map.items():
|
||||||
|
if camel in data:
|
||||||
|
val = data[camel]
|
||||||
|
# bool → int (SQLite)
|
||||||
|
if isinstance(val, bool):
|
||||||
|
val = 1 if val else 0
|
||||||
|
updates[snake] = val
|
||||||
|
if "specialQuals" in data:
|
||||||
|
updates["special_quals"] = json.dumps(data["specialQuals"])
|
||||||
|
|
||||||
|
with _conn() as conn:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM subscription_profile WHERE id = 1"
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
if updates:
|
||||||
|
set_clauses = ", ".join(f"{k} = ?" for k in updates)
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE subscription_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 subscription_profile ({', '.join(cols)}) VALUES ({placeholders})",
|
||||||
|
vals,
|
||||||
|
)
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM subscription_profile WHERE id = 1"
|
||||||
|
).fetchone()
|
||||||
|
return _profile_row_to_dict(row)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ from .db import (
|
|||||||
get_all_posts, create_post, update_post, delete_post,
|
get_all_posts, create_post, update_post, delete_post,
|
||||||
# realestate
|
# realestate
|
||||||
get_all_complexes, get_complex, create_complex, update_complex, delete_complex,
|
get_all_complexes, get_complex, create_complex, update_complex, delete_complex,
|
||||||
|
# subscription
|
||||||
|
get_all_subscription_items, create_subscription_item,
|
||||||
|
update_subscription_item, delete_subscription_item,
|
||||||
|
get_subscription_profile, upsert_subscription_profile,
|
||||||
)
|
)
|
||||||
from .recommender import recommend_numbers, recommend_with_heatmap
|
from .recommender import recommend_numbers, recommend_with_heatmap
|
||||||
from .collector import sync_latest, sync_ensure_all
|
from .collector import sync_latest, sync_ensure_all
|
||||||
@@ -751,3 +755,110 @@ def api_realestate_delete(complex_id: int):
|
|||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(status_code=404, detail="Complex not found")
|
raise HTTPException(status_code=404, detail="Complex not found")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Subscription API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SubscriptionItemCreate(BaseModel):
|
||||||
|
complexName: str
|
||||||
|
address: str = ""
|
||||||
|
pyeong: Optional[str] = None
|
||||||
|
totalPrice: Optional[int] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
specialType: Optional[str] = None
|
||||||
|
supplyType: Optional[str] = None
|
||||||
|
status: str = "검토중"
|
||||||
|
minScore: Optional[int] = None
|
||||||
|
maxIncome: Optional[int] = None
|
||||||
|
homelessRequired: Optional[int] = None
|
||||||
|
subscriptionStart: Optional[str] = None
|
||||||
|
subscriptionEnd: Optional[str] = None
|
||||||
|
contractDate: Optional[str] = None
|
||||||
|
interimDate: Optional[str] = None
|
||||||
|
balanceDate: Optional[str] = None
|
||||||
|
resultDate: Optional[str] = None
|
||||||
|
depositRate: int = 10
|
||||||
|
interimRate: int = 60
|
||||||
|
balanceRate: int = 30
|
||||||
|
loanType: Optional[str] = None
|
||||||
|
loanRate: Optional[float] = None
|
||||||
|
memo: str = ""
|
||||||
|
naverUrl: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionItemUpdate(BaseModel):
|
||||||
|
complexName: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
pyeong: Optional[str] = None
|
||||||
|
totalPrice: Optional[int] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
specialType: Optional[str] = None
|
||||||
|
supplyType: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
minScore: Optional[int] = None
|
||||||
|
maxIncome: Optional[int] = None
|
||||||
|
homelessRequired: Optional[int] = None
|
||||||
|
subscriptionStart: Optional[str] = None
|
||||||
|
subscriptionEnd: Optional[str] = None
|
||||||
|
contractDate: Optional[str] = None
|
||||||
|
interimDate: Optional[str] = None
|
||||||
|
balanceDate: Optional[str] = None
|
||||||
|
resultDate: Optional[str] = None
|
||||||
|
depositRate: Optional[int] = None
|
||||||
|
interimRate: Optional[int] = None
|
||||||
|
balanceRate: Optional[int] = None
|
||||||
|
loanType: Optional[str] = None
|
||||||
|
loanRate: Optional[float] = None
|
||||||
|
memo: Optional[str] = None
|
||||||
|
naverUrl: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionProfile(BaseModel):
|
||||||
|
isHouseholdHead: Optional[bool] = None
|
||||||
|
isHomeless: Optional[bool] = None
|
||||||
|
homelessPeriod: Optional[int] = None
|
||||||
|
savingsMonths: Optional[int] = None
|
||||||
|
savingsCount: Optional[int] = None
|
||||||
|
dependents: Optional[int] = None
|
||||||
|
residencyArea: Optional[str] = None
|
||||||
|
isMarried: Optional[bool] = None
|
||||||
|
marriageMonths: Optional[int] = None
|
||||||
|
monthlyIncome: Optional[int] = None
|
||||||
|
specialQuals: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/subscription/items")
|
||||||
|
def api_subscription_list():
|
||||||
|
return get_all_subscription_items()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/subscription/items", status_code=201)
|
||||||
|
def api_subscription_create(body: SubscriptionItemCreate):
|
||||||
|
return create_subscription_item(body.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/subscription/items/{item_id}")
|
||||||
|
def api_subscription_update(item_id: int, body: SubscriptionItemUpdate):
|
||||||
|
updated = update_subscription_item(item_id, body.model_dump(exclude_none=True))
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/subscription/items/{item_id}")
|
||||||
|
def api_subscription_delete(item_id: int):
|
||||||
|
ok = delete_subscription_item(item_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/subscription/profile")
|
||||||
|
def api_subscription_profile_get():
|
||||||
|
profile = get_subscription_profile()
|
||||||
|
return profile if profile is not None else {}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/subscription/profile")
|
||||||
|
def api_subscription_profile_put(body: SubscriptionProfile):
|
||||||
|
return upsert_subscription_profile(body.model_dump(exclude_none=True))
|
||||||
|
|||||||
Reference in New Issue
Block a user