diff --git a/app/modules/cupang/db.py b/app/modules/cupang/db.py index 17ab9f0..d404673 100644 --- a/app/modules/cupang/db.py +++ b/app/modules/cupang/db.py @@ -18,6 +18,7 @@ from datetime import date, datetime, timezone from typing import Any from psycopg.rows import dict_row +from psycopg.types.json import Jsonb from psycopg_pool import ConnectionPool from app.timezone import KST @@ -194,6 +195,90 @@ class CupangDBStore: if cur.rowcount == 0: raise KeyError(rule_id) + # ════════════════════════════════════════════════════════════ + # 박스 계산 임시 저장 (cupang_box_calc_drafts) + # payload 는 화면 상태 스냅샷(JSONB). 박스 수 계산은 불러온 뒤 + # 서버에서 다시 하므로 여기 값은 신뢰 대상이 아니다. + # ════════════════════════════════════════════════════════════ + def list_box_calc_drafts(self, *, limit: int = 50) -> list[dict[str, Any]]: + """목록용 — payload 는 크므로 제외한다.""" + with self._pool.connection() as conn: + rows = conn.execute( + """ + SELECT id, title, created_by, created_at, updated_at + FROM cupang_box_calc_drafts + ORDER BY updated_at DESC + LIMIT %s + """, + (int(limit),), + ).fetchall() + return [self._draft_serialize(r) for r in rows] + + def get_box_calc_draft(self, *, draft_id: int) -> dict[str, Any] | None: + with self._pool.connection() as conn: + row = conn.execute( + "SELECT * FROM cupang_box_calc_drafts WHERE id = %s", (draft_id,) + ).fetchone() + return self._draft_serialize(row) if row else None + + def save_box_calc_draft( + self, + *, + title: str, + payload: dict[str, Any], + created_by: str = "", + draft_id: int | None = None, + ) -> dict[str, Any]: + """draft_id 가 있으면 덮어쓰고, 없으면 새로 만든다. + + 같은 제목이 이미 있으면 그 건을 덮어쓴다(임시 저장이라 목록이 무한히 + 늘어나지 않게). + """ + name = (title or "").strip() + if not name: + raise ValueError("제목 필수") + data = Jsonb(payload if isinstance(payload, dict) else {}) + + with self._pool.connection() as conn: + if draft_id is None: + hit = conn.execute( + "SELECT id FROM cupang_box_calc_drafts WHERE title = %s", + (name,), + ).fetchone() + if hit: + draft_id = int(hit["id"]) + + if draft_id is not None: + row = conn.execute( + """ + UPDATE cupang_box_calc_drafts + SET title = %s, payload = %s, created_by = %s + WHERE id = %s + RETURNING * + """, + (name, data, (created_by or "").strip(), draft_id), + ).fetchone() + if not row: + raise KeyError(draft_id) + else: + row = conn.execute( + """ + INSERT INTO cupang_box_calc_drafts (title, payload, created_by) + VALUES (%s, %s, %s) + RETURNING * + """, + (name, data, (created_by or "").strip()), + ).fetchone() + return self._draft_serialize(row) + + def delete_box_calc_draft(self, *, draft_id: int) -> None: + with self._pool.connection() as conn: + cur = conn.execute( + "DELETE FROM cupang_box_calc_drafts WHERE id = %s", (draft_id,) + ) + if cur.rowcount == 0: + raise KeyError(draft_id) + # ════════════════════════════════════════════════════════════ # 제품명 카탈로그 (cupang_products) # itemcode_db 에서 가져와 등록한 제품 목록. 폼의 제품명 드롭다운 소스. @@ -577,6 +662,21 @@ class CupangDBStore: out[k] = v.astimezone(KST).isoformat(timespec="seconds") return out + @staticmethod + def _draft_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None: + if row is None: + return None + out = dict(row) + out["id"] = int(out["id"]) + out["title"] = str(out.get("title") or "") + if "payload" in out and not isinstance(out.get("payload"), dict): + out["payload"] = {} + for k in ("created_at", "updated_at"): + v = out.get(k) + if isinstance(v, datetime): + out[k] = v.astimezone(KST).isoformat(timespec="seconds") + return out + @staticmethod def _product_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None: if row is None: diff --git a/app/modules/cupang/router.py b/app/modules/cupang/router.py index 303904d..5c9f553 100644 --- a/app/modules/cupang/router.py +++ b/app/modules/cupang/router.py @@ -908,6 +908,88 @@ async def product_delete( return RedirectResponse(url="/cupang/products", status_code=303) +# ════════════════════════════════════════════════════════════ +# 박스 계산 임시 저장 (화면 상태 스냅샷) +# ════════════════════════════════════════════════════════════ +@router.get("/api/box-calc/drafts") +async def box_calc_drafts_list( + request: Request, + user: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="cupang_db 미설정") + return JSONResponse({"drafts": store.list_box_calc_drafts()}) + + +@router.get("/api/box-calc/drafts/{draft_id:int}") +async def box_calc_draft_get( + request: Request, + draft_id: int, + user: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="cupang_db 미설정") + draft = store.get_box_calc_draft(draft_id=draft_id) + if draft is None: + raise HTTPException(status_code=404, detail="저장된 내용을 찾을 수 없습니다.") + return JSONResponse({"draft": draft}) + + +@router.post("/api/box-calc/drafts") +async def box_calc_draft_save( + request: Request, + payload: dict[str, Any] = Body(...), + user: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + """{title, payload, draft_id?} → 저장(같은 제목이면 덮어쓰기).""" + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="cupang_db 미설정") + + title = str(payload.get("title") or "").strip() + if not title: + raise HTTPException(status_code=400, detail="제목을 입력하세요.") + if len(title) > 100: + title = title[:100] + snapshot = payload.get("payload") + if not isinstance(snapshot, dict): + raise HTTPException(status_code=400, detail="payload 는 객체여야 합니다.") + + raw_id = payload.get("draft_id") + draft_id = int(raw_id) if isinstance(raw_id, int) or (isinstance(raw_id, str) and raw_id.isdigit()) else None + + try: + draft = store.save_box_calc_draft( + title=title, + payload=snapshot, + created_by=str(user.get("name") or user.get("email") or ""), + draft_id=draft_id, + ) + except KeyError: + raise HTTPException(status_code=404, detail="저장된 내용을 찾을 수 없습니다.") + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return JSONResponse({"draft": draft}) + + +@router.delete("/api/box-calc/drafts/{draft_id:int}") +async def box_calc_draft_delete( + request: Request, + draft_id: int, + user: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="cupang_db 미설정") + try: + store.delete_box_calc_draft(draft_id=draft_id) + except KeyError: + raise HTTPException(status_code=404, detail="저장된 내용을 찾을 수 없습니다.") + return JSONResponse({"deleted": True}) + + @router.get("/health") async def health() -> dict[str, str]: return {"status": "ok", "module": "cupang"} diff --git a/app/modules/cupang/templates/cupang/box_calc.html b/app/modules/cupang/templates/cupang/box_calc.html index 8c2c921..e6fe466 100644 --- a/app/modules/cupang/templates/cupang/box_calc.html +++ b/app/modules/cupang/templates/cupang/box_calc.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
@@ -51,6 +51,8 @@ + + @@ -121,6 +123,31 @@ + + +