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 @@
+
+
+
+
+
임시 저장
+
+
+
+
+
+
+
+
저장된 목록
+
+
+
+
+
+