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:
@@ -18,6 +18,7 @@ from datetime import date, datetime, timezone
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
from psycopg_pool import ConnectionPool
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
from app.timezone import KST
|
from app.timezone import KST
|
||||||
@@ -194,6 +195,90 @@ class CupangDBStore:
|
|||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
raise KeyError(rule_id)
|
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)
|
# 제품명 카탈로그 (cupang_products)
|
||||||
# itemcode_db 에서 가져와 등록한 제품 목록. 폼의 제품명 드롭다운 소스.
|
# itemcode_db 에서 가져와 등록한 제품 목록. 폼의 제품명 드롭다운 소스.
|
||||||
@@ -577,6 +662,21 @@ class CupangDBStore:
|
|||||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||||
return out
|
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
|
@staticmethod
|
||||||
def _product_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
def _product_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
if row is None:
|
if row is None:
|
||||||
|
|||||||
@@ -908,6 +908,88 @@ async def product_delete(
|
|||||||
return RedirectResponse(url="/cupang/products", status_code=303)
|
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")
|
@router.get("/health")
|
||||||
async def health() -> dict[str, str]:
|
async def health() -> dict[str, str]:
|
||||||
return {"status": "ok", "module": "cupang"}
|
return {"status": "ok", "module": "cupang"}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831e" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831f" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
@@ -51,6 +51,8 @@
|
|||||||
<button type="button" class="erp-btn erp-btn-primary" id="cpg-calc-run">계산 / 재계산</button>
|
<button type="button" class="erp-btn erp-btn-primary" id="cpg-calc-run">계산 / 재계산</button>
|
||||||
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-add">+ 제품 추가</button>
|
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-add">+ 제품 추가</button>
|
||||||
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-reset">초기화</button>
|
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-reset">초기화</button>
|
||||||
|
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-save">임시 저장</button>
|
||||||
|
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-load">불러오기</button>
|
||||||
<span class="erp-muted" id="cpg-calc-msg"></span>
|
<span class="erp-muted" id="cpg-calc-msg"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,6 +123,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 임시 저장 / 불러오기 -->
|
||||||
|
<div class="cpg-modal" id="cpg-draft-dlg" hidden>
|
||||||
|
<div class="cpg-modal-back" data-draft-close></div>
|
||||||
|
<div class="cpg-modal-box cpg-draft-box" role="dialog" aria-modal="true" aria-labelledby="cpg-draft-title">
|
||||||
|
<h3 id="cpg-draft-title">임시 저장</h3>
|
||||||
|
|
||||||
|
<label class="erp-field"><span>제목</span>
|
||||||
|
<input class="erp-input" type="text" id="cpg-draft-name" maxlength="100"
|
||||||
|
placeholder="예: 8/31 인천분" autocomplete="off" /></label>
|
||||||
|
<div class="cpg-draft-saveact">
|
||||||
|
<button type="button" class="erp-btn erp-btn-primary" id="cpg-draft-save">이 내용 저장</button>
|
||||||
|
<span class="erp-muted" id="cpg-draft-msg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="cpg-draft-h4">저장된 목록</h4>
|
||||||
|
<div class="cpg-draft-list" id="cpg-draft-list">
|
||||||
|
<p class="erp-muted">불러오는 중…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cpg-dlg-actions cpg-draft-foot">
|
||||||
|
<button type="button" class="erp-btn erp-btn-outline" data-draft-close>닫기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="application/json" id="cpg-calc-rules">{{ box_rules | tojson }}</script>
|
<script type="application/json" id="cpg-calc-rules">{{ box_rules | tojson }}</script>
|
||||||
<script type="application/json" id="cpg-centers">{{ centers | tojson }}</script>
|
<script type="application/json" id="cpg-centers">{{ centers | tojson }}</script>
|
||||||
<script>
|
<script>
|
||||||
@@ -215,7 +242,7 @@
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function run() {
|
function run(restore) {
|
||||||
var items = collect();
|
var items = collect();
|
||||||
if (!items.length) { msg.textContent = "제품명을 하나 이상 선택하세요."; return; }
|
if (!items.length) { msg.textContent = "제품명을 하나 이상 선택하세요."; return; }
|
||||||
msg.textContent = "계산 중…";
|
msg.textContent = "계산 중…";
|
||||||
@@ -251,8 +278,14 @@
|
|||||||
alloc = {};
|
alloc = {};
|
||||||
stamped = {};
|
stamped = {};
|
||||||
allStamped = false; // 계산이 바뀌면 이전 배분은 근거가 사라지므로 비운다.
|
allStamped = false; // 계산이 바뀌면 이전 배분은 근거가 사라지므로 비운다.
|
||||||
|
var skipped = restore ? applyRestore(restore) : 0;
|
||||||
render();
|
render();
|
||||||
msg.textContent = "계산 완료 (" + calc.results.length + "건)" + (had ? " — 센터 분배 초기화됨" : "");
|
if (restore) {
|
||||||
|
msg.textContent = "불러오기 완료 (" + calc.results.length + "건)" +
|
||||||
|
(skipped ? " — 담을 수 없는 " + skipped + "건은 제외" : "");
|
||||||
|
} else {
|
||||||
|
msg.textContent = "계산 완료 (" + calc.results.length + "건)" + (had ? " — 센터 분배 초기화됨" : "");
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(function () { msg.textContent = "계산 실패 — 새로고침 후 다시 시도하세요."; })
|
.catch(function () { msg.textContent = "계산 실패 — 새로고침 후 다시 시도하세요."; })
|
||||||
.then(function () { runBtn.disabled = false; });
|
.then(function () { runBtn.disabled = false; });
|
||||||
@@ -745,6 +778,157 @@
|
|||||||
msg.textContent = "";
|
msg.textContent = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 임시 저장 / 불러오기 ──────────────────────────
|
||||||
|
var saveBtn = document.getElementById("cpg-calc-save");
|
||||||
|
var loadBtn = document.getElementById("cpg-calc-load");
|
||||||
|
var draftDlg = document.getElementById("cpg-draft-dlg");
|
||||||
|
var draftName = document.getElementById("cpg-draft-name");
|
||||||
|
var draftSave = document.getElementById("cpg-draft-save");
|
||||||
|
var draftMsg = document.getElementById("cpg-draft-msg");
|
||||||
|
var draftList = document.getElementById("cpg-draft-list");
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
return {
|
||||||
|
items: collect(),
|
||||||
|
openCenters: openCenters.slice(),
|
||||||
|
alloc: alloc,
|
||||||
|
stamped: stamped,
|
||||||
|
allStamped: allStamped
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 불러온 분배 내역을 현재 계산 결과 위에 다시 얹는다.
|
||||||
|
// 계산이 달라져 사라진 항목은 건너뛰고 그 건수를 돌려준다.
|
||||||
|
function applyRestore(snap) {
|
||||||
|
var skipped = 0;
|
||||||
|
openCenters = [];
|
||||||
|
(snap.openCenters || []).forEach(function (cid) {
|
||||||
|
cid = String(cid);
|
||||||
|
if (openCenters.indexOf(cid) < 0) openCenters.push(cid);
|
||||||
|
});
|
||||||
|
var src = snap.alloc || {};
|
||||||
|
Object.keys(src).forEach(function (cid) {
|
||||||
|
(src[cid] || []).forEach(function (a) {
|
||||||
|
if (!a || !(a.key in units)) { skipped += 1; return; }
|
||||||
|
if (openCenters.indexOf(String(cid)) < 0) openCenters.push(String(cid));
|
||||||
|
if (addAlloc(cid, a.key, a.count) <= 0) skipped += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
stamped = snap.stamped && typeof snap.stamped === "object" ? snap.stamped : {};
|
||||||
|
allStamped = !!snap.allStamped;
|
||||||
|
return skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTitle() {
|
||||||
|
var d = new Date();
|
||||||
|
function pad(n) { return (n < 10 ? "0" : "") + n; }
|
||||||
|
return (d.getMonth() + 1) + "/" + d.getDate() + " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDraft() { draftDlg.hidden = true; }
|
||||||
|
|
||||||
|
function openDraft(mode) {
|
||||||
|
draftMsg.textContent = "";
|
||||||
|
document.getElementById("cpg-draft-title").textContent =
|
||||||
|
mode === "load" ? "저장된 내용 불러오기" : "임시 저장";
|
||||||
|
if (mode !== "load") draftName.value = defaultTitle();
|
||||||
|
draftDlg.hidden = false;
|
||||||
|
loadDrafts();
|
||||||
|
if (mode !== "load") { draftName.focus(); draftName.select(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDrafts() {
|
||||||
|
draftList.innerHTML = '<p class="erp-muted">불러오는 중…</p>';
|
||||||
|
fetch("/cupang/api/box-calc/drafts")
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
var rows = (data && data.drafts) || [];
|
||||||
|
if (!rows.length) {
|
||||||
|
draftList.innerHTML = '<p class="erp-muted">저장된 내용이 없습니다.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draftList.innerHTML = rows.map(function (d) {
|
||||||
|
return '<div class="cpg-draft-row">' +
|
||||||
|
'<span class="cpg-draft-rname">' + esc(d.title) + "</span>" +
|
||||||
|
'<span class="cpg-draft-rmeta">' + esc((d.updated_at || "").replace("T", " ").slice(5, 16)) +
|
||||||
|
(d.created_by ? " · " + esc(d.created_by) : "") + "</span>" +
|
||||||
|
'<button type="button" class="erp-btn erp-btn-outline cpg-btn-sm" data-draft-load="' + d.id + '">불러오기</button>' +
|
||||||
|
'<button type="button" class="erp-btn erp-btn-danger cpg-btn-sm" data-draft-del="' + d.id + '">삭제</button>' +
|
||||||
|
"</div>";
|
||||||
|
}).join("");
|
||||||
|
})
|
||||||
|
.catch(function () { draftList.innerHTML = '<p class="erp-muted">목록을 불러오지 못했습니다.</p>'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSave() {
|
||||||
|
var title = (draftName.value || "").trim();
|
||||||
|
if (!title) { draftMsg.textContent = "제목을 입력하세요."; draftName.focus(); return; }
|
||||||
|
draftSave.disabled = true;
|
||||||
|
draftMsg.textContent = "저장 중…";
|
||||||
|
fetch("/cupang/api/box-calc/drafts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: title, payload: snapshot() })
|
||||||
|
})
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
|
||||||
|
.then(function () {
|
||||||
|
draftMsg.textContent = "저장됨 (같은 제목은 덮어씀)";
|
||||||
|
msg.textContent = '"' + title + '" 임시 저장됨';
|
||||||
|
loadDrafts();
|
||||||
|
})
|
||||||
|
.catch(function () { draftMsg.textContent = "저장 실패 — 다시 시도하세요."; })
|
||||||
|
.then(function () { draftSave.disabled = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function doLoad(id) {
|
||||||
|
draftMsg.textContent = "불러오는 중…";
|
||||||
|
fetch("/cupang/api/box-calc/drafts/" + id)
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
var d = (data && data.draft) || {};
|
||||||
|
var snap = d.payload || {};
|
||||||
|
var items = snap.items || [];
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
items.forEach(function (it) { addRow(it.product_code, it.quantity); });
|
||||||
|
if (!items.length) addRow("", 0);
|
||||||
|
draftName.value = d.title || "";
|
||||||
|
closeDraft();
|
||||||
|
// 박스 수는 서버에서 다시 계산한 뒤 분배 내역을 얹는다.
|
||||||
|
run(snap);
|
||||||
|
})
|
||||||
|
.catch(function () { draftMsg.textContent = "불러오기 실패 — 다시 시도하세요."; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function doDelete(id) {
|
||||||
|
fetch("/cupang/api/box-calc/drafts/" + id, { method: "DELETE" })
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
|
||||||
|
.then(function () { draftMsg.textContent = "삭제됨"; loadDrafts(); })
|
||||||
|
.catch(function () { draftMsg.textContent = "삭제 실패 — 다시 시도하세요."; });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveBtn) saveBtn.addEventListener("click", function () { openDraft("save"); });
|
||||||
|
if (loadBtn) loadBtn.addEventListener("click", function () { openDraft("load"); });
|
||||||
|
if (draftSave) draftSave.addEventListener("click", doSave);
|
||||||
|
if (draftName) {
|
||||||
|
draftName.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); doSave(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (draftDlg) {
|
||||||
|
Array.prototype.forEach.call(draftDlg.querySelectorAll("[data-draft-close]"), function (el) {
|
||||||
|
el.addEventListener("click", closeDraft);
|
||||||
|
});
|
||||||
|
draftDlg.addEventListener("click", function (e) {
|
||||||
|
var l = e.target.closest("[data-draft-load]");
|
||||||
|
if (l) { doLoad(l.getAttribute("data-draft-load")); return; }
|
||||||
|
var x = e.target.closest("[data-draft-del]");
|
||||||
|
if (x) { doDelete(x.getAttribute("data-draft-del")); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && draftDlg && !draftDlg.hidden) closeDraft();
|
||||||
|
});
|
||||||
|
|
||||||
addRow("", 0);
|
addRow("", 0);
|
||||||
render();
|
render();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831e" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831f" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831e" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831f" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831e" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831f" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831e" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831f" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831e" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831f" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831e" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831f" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -809,6 +809,37 @@ body.cpg-dragging { cursor: grabbing; user-select: none; }
|
|||||||
.cpg-dist-card.is-drop-ready { outline: 2px dashed var(--color-rich-black); outline-offset: 2px; }
|
.cpg-dist-card.is-drop-ready { outline: 2px dashed var(--color-rich-black); outline-offset: 2px; }
|
||||||
.cpg-dist-card.is-drop-ready .cpg-dist-center { border-color: var(--color-midtone-gray); }
|
.cpg-dist-card.is-drop-ready .cpg-dist-center { border-color: var(--color-midtone-gray); }
|
||||||
|
|
||||||
|
/* 임시 저장 / 불러오기 대화상자 */
|
||||||
|
.cpg-draft-box { width: min(520px, calc(100vw - 32px)); }
|
||||||
|
.cpg-draft-saveact { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.cpg-draft-h4 {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--color-subtle-ash);
|
||||||
|
font-size: 13px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.cpg-draft-list {
|
||||||
|
max-height: 260px; overflow-y: auto;
|
||||||
|
border: 1px solid var(--color-subtle-ash); border-radius: 8px;
|
||||||
|
}
|
||||||
|
.cpg-draft-list > p { margin: 0; padding: 12px; font-size: 13px; }
|
||||||
|
.cpg-draft-row {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
.cpg-draft-row + .cpg-draft-row { border-top: 1px solid var(--color-ghost-gray); }
|
||||||
|
.cpg-draft-row:hover { background: var(--color-ghost-gray); }
|
||||||
|
.cpg-draft-rname {
|
||||||
|
flex: 1 1 auto; min-width: 0; font-size: 13px; font-weight: 600;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cpg-draft-rmeta {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-family: var(--font-geist-mono);
|
||||||
|
font-size: 11px; color: var(--color-midtone-gray);
|
||||||
|
}
|
||||||
|
.cpg-draft-foot { display: flex; justify-content: flex-end; }
|
||||||
|
|
||||||
/* 대화상자 센터 버튼 목록 */
|
/* 대화상자 센터 버튼 목록 */
|
||||||
.cpg-dlg-centers {
|
.cpg-dlg-centers {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -201,11 +201,14 @@ DDL: `scripts/sql/cupang_db_init.sql` (멱등). DB·역할(`cupang_app`)·테이
|
|||||||
| `cupang_box_rules` | 제품코드별 박스당 입수량(`units_per_box`). `product_code` UNIQUE |
|
| `cupang_box_rules` | 제품코드별 박스당 입수량(`units_per_box`). `product_code` UNIQUE |
|
||||||
| `cupang_shipments` | 출고 묶음 헤더 (작성일/출고일/센터입고일/센터/출고방식/상태/작업자/메모) |
|
| `cupang_shipments` | 출고 묶음 헤더 (작성일/출고일/센터입고일/센터/출고방식/상태/작업자/메모) |
|
||||||
| `cupang_shipment_lines` | 출고 라인. `shipment_id` FK ON DELETE CASCADE. `UNIQUE(shipment_id, line_no)` |
|
| `cupang_shipment_lines` | 출고 라인. `shipment_id` FK ON DELETE CASCADE. `UNIQUE(shipment_id, line_no)` |
|
||||||
|
| `cupang_box_calc_drafts` | 박스 계산 화면 임시 저장. `title` + `payload`(JSONB: 입력 품목·열어둔 센터·분배 내역). 같은 제목이면 덮어씀 |
|
||||||
|
|
||||||
`status` 허용값: `작성중`, `출고준비`, `출고완료`, `센터입고완료`, `취소`. 삭제는 기본 soft delete(`status='취소'`).
|
`status` 허용값: `작성중`, `출고준비`, `출고완료`, `센터입고완료`, `취소`. 삭제는 기본 soft delete(`status='취소'`).
|
||||||
|
|
||||||
박스 계산은 서버(`store.compute_boxes`)에서 재계산: `required_boxes = ceil(quantity / units_per_box)`. 클라이언트 계산은 미리보기용.
|
박스 계산은 서버(`store.compute_boxes`)에서 재계산: `required_boxes = ceil(quantity / units_per_box)`. 클라이언트 계산은 미리보기용.
|
||||||
|
|
||||||
|
임시 저장(`cupang_box_calc_drafts`)은 화면 상태 스냅샷만 담는다. 불러올 때 박스 수는 저장값을 쓰지 않고 서버에서 다시 계산하고, 그 위에 분배 내역을 얹는다(사라진 항목은 제외). 마이그레이션: `scripts/sql/cupang_db_003_box_calc_drafts.sql`.
|
||||||
|
|
||||||
상품은 `cupang_db` 에 복제 저장하지 않는다. 라인에는 `product_code` + `product_name_snapshot` 만 보존(과거 명칭 보존). 상품 검색은 `itemcode_db` **읽기 전용**(`ITEMCODE_DB_URL`, 미설정 시 수동 입력).
|
상품은 `cupang_db` 에 복제 저장하지 않는다. 라인에는 `product_code` + `product_name_snapshot` 만 보존(과거 명칭 보존). 상품 검색은 `itemcode_db` **읽기 전용**(`ITEMCODE_DB_URL`, 미설정 시 수동 입력).
|
||||||
|
|
||||||
### 운영 서버 초기화 (1회, 사용자 승인 후)
|
### 운영 서버 초기화 (1회, 사용자 승인 후)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- cupang_db 마이그레이션 003 — 박스 계산 임시 저장 (cupang_box_calc_drafts)
|
||||||
|
-- =====================================================================
|
||||||
|
-- 사유: 박스 계산 화면(①입력 / ②요약 / ③센터 분배)의 작업 중 상태를
|
||||||
|
-- 이름 붙여 서버에 저장하고 나중에 다시 불러오기 위함.
|
||||||
|
-- payload 는 화면 상태 스냅샷(JSONB): 입력 품목·열어둔 센터·분배 내역.
|
||||||
|
-- 박스 수 계산 자체는 불러온 뒤 서버에서 다시 수행한다(값 신뢰 안 함).
|
||||||
|
--
|
||||||
|
-- 멱등: CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS.
|
||||||
|
-- 기존 데이터를 지우지 않는다. DROP/TRUNCATE 없음.
|
||||||
|
--
|
||||||
|
-- 실행:
|
||||||
|
-- docker exec -i postgres-db psql -U postgres -d cupang_db \
|
||||||
|
-- < /opt/www/main/scripts/sql/cupang_db_003_box_calc_drafts.sql
|
||||||
|
--
|
||||||
|
-- 확인:
|
||||||
|
-- docker exec -i postgres-db psql -U postgres -d cupang_db \
|
||||||
|
-- -c "\d cupang_box_calc_drafts"
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
\connect cupang_db
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cupang_box_calc_drafts (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cupang_box_calc_drafts_updated
|
||||||
|
ON cupang_box_calc_drafts (updated_at DESC);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_cupang_box_calc_drafts_updated ON cupang_box_calc_drafts;
|
||||||
|
CREATE TRIGGER trg_cupang_box_calc_drafts_updated
|
||||||
|
BEFORE UPDATE ON cupang_box_calc_drafts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
|
||||||
|
|
||||||
|
-- 앱 계정 권한 (init 스크립트와 동일하게 CRUD 만)
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON cupang_box_calc_drafts TO cupang_app;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE cupang_box_calc_drafts_id_seq TO cupang_app;
|
||||||
|
|
||||||
|
SELECT 'cupang_db 003 done' AS status;
|
||||||
Reference in New Issue
Block a user