feat(cupang): 박스 계산 임시 저장 / 불러오기

계산 중 화면 상태를 서버에 이름 붙여 저장하고 다시 불러온다. 다른 PC 에서도
이어서 작업할 수 있어야 해서 localStorage 대신 DB 에 둔다.

저장 대상은 화면 스냅샷(입력 품목·열어둔 센터·분배 내역·도장 상태)뿐이고,
박스 수는 불러온 뒤 기존 계산 API 로 서버에서 다시 구한다. 저장값을 그대로
믿지 않으므로 입수량 규칙이 바뀌어도 어긋나지 않고, 계산 결과에서 사라진
항목은 제외한 건수를 알려준다. 같은 제목으로 저장하면 덮어써 목록이 무한히
늘어나지 않는다.

- scripts/sql/cupang_db_003_box_calc_drafts.sql (신규 테이블 + 권한, 멱등)
- db.py: list/get/save/delete_box_calc_draft
- router: GET·POST /cupang/api/box-calc/drafts, GET·DELETE /{id}
- box_calc.html: [임시 저장]·[불러오기] 버튼 + 목록 대화상자
This commit is contained in:
2026-08-31 16:04:44 +09:00
parent bd53533b77
commit b3e1f0a462
12 changed files with 454 additions and 9 deletions
+100
View File
@@ -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: