diff --git a/app/modules/malaysia/db.py b/app/modules/malaysia/db.py
index 05e734e..53140ed 100644
--- a/app/modules/malaysia/db.py
+++ b/app/modules/malaysia/db.py
@@ -431,13 +431,67 @@ class MalaysiaStockStore:
"JOIN daily_stocktake_rack r ON r.stocktake_id = s.id "
"WHERE s.warehouse_code = %s AND s.id <> %s AND s.status <> 'cancelled' "
"GROUP BY s.id "
- "ORDER BY s.stocktake_date DESC, s.id DESC LIMIT 1",
+ "ORDER BY MAX(r.created_at) DESC, s.id DESC LIMIT 1",
(warehouse_code, exclude_stocktake_id),
).fetchone()
if not head:
return []
return self.list_rack_entries(stocktake_id=head["id"])
+ def reposition_rack(
+ self, *, stocktake_id: int, entries: list[dict[str, Any]]
+ ) -> dict[str, Any]:
+ """랙 위치 재배치 전용(랙 보기 드래그 저장).
+
+ daily_stocktake_rack 의 칸(cell_code) 배치만 교체한다. 위치 이동은
+ SKU 총수량을 바꾸지 않으므로 daily_stocktake_line 은 건드리지 않는다
+ (확정본의 집계/전산재고 보존). 확정(finalized) 조사도 허용 — 위치만
+ 변경. 취소된 조사는 불가. entries 형식은 replace_rack 과 동일.
+ """
+ valid_cells = set(store.rack_cell_codes())
+ clean: list[tuple[str, str, int, int]] = []
+ for e in entries:
+ cell = (e.get("cell_code") or "").strip().upper()
+ if cell not in valid_cells:
+ raise ValueError(f"알 수 없는 랙 위치: {cell or '(빈값)'}")
+ code = store.validate_stocktake_code(str(e.get("sku_code") or ""))
+ upb = store.validate_qty(
+ e.get("units_per_box"), allow_zero=False, allow_negative=False
+ )
+ box = store.validate_qty(
+ e.get("box_count"), allow_zero=False, allow_negative=False
+ )
+ clean.append((cell, code, upb, box))
+
+ cell_seq: dict[str, int] = {}
+ with self._pool.connection() as conn:
+ with conn.transaction():
+ head = conn.execute(
+ "SELECT status FROM daily_stocktake WHERE id = %s FOR UPDATE",
+ (stocktake_id,),
+ ).fetchone()
+ if not head:
+ raise KeyError(stocktake_id)
+ if head["status"] == "cancelled":
+ raise ValueError("취소된 조사는 수정할 수 없습니다.")
+ conn.execute(
+ "DELETE FROM daily_stocktake_rack WHERE stocktake_id = %s",
+ (stocktake_id,),
+ )
+ for cell, code, upb, box in clean:
+ ln = cell_seq.get(cell, 0)
+ cell_seq[cell] = ln + 1
+ conn.execute(
+ """
+ INSERT INTO daily_stocktake_rack
+ (stocktake_id, cell_code, line_no, sku_code,
+ units_per_box, box_count)
+ VALUES (%s,%s,%s,%s,%s,%s)
+ """,
+ (stocktake_id, cell, ln, code, upb, box),
+ )
+ return {"entries": len(clean)}
+
def delete_stocktake(self, *, stocktake_id: int) -> None:
"""재고조사 완전 삭제(헤더+라인 CASCADE). 슈퍼관리자 전용 동작.
diff --git a/app/modules/malaysia/router.py b/app/modules/malaysia/router.py
index 1e69079..9238c82 100644
--- a/app/modules/malaysia/router.py
+++ b/app/modules/malaysia/router.py
@@ -209,6 +209,32 @@ async def rack_view(request: Request) -> HTMLResponse:
return render_template(request, "malaysia/rack_view.html", ctx)
+@router.post("/rack/save")
+async def rack_save(
+ request: Request, user: dict[str, Any] = Depends(_require_user)
+) -> RedirectResponse:
+ """랙 보기에서 드래그로 재배치한 칸 구성을 저장(위치만 변경).
+
+ 대상은 랙 보기가 보여주는 조사(latest_rack_stocktake). 수량 합계는
+ 불변이며 확정본도 허용. 폼은 stocktake_detail 과 동일한 병렬배열
+ (rk_cell/rk_sku/rk_upb/rk_box) → _parse_rack_form 재사용.
+ """
+ st = _store(request)
+ if st is None:
+ raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
+ form = await request.form()
+ wh = str(form.get("warehouse_code") or "").strip() or _selected_wh(request, st)
+ latest = st.latest_rack_stocktake(warehouse_code=wh)
+ if not latest:
+ raise HTTPException(status_code=400, detail="저장할 랙 재고조사가 없습니다.")
+ entries = _parse_rack_form(form)
+ try:
+ st.reposition_rack(stocktake_id=latest["id"], entries=entries)
+ except (KeyError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+ return RedirectResponse(url=f"/malaysia/rack?wh={wh}", status_code=303)
+
+
@router.post("/reset")
async def reset_data(
request: Request, user: dict[str, Any] = Depends(_require_user)
diff --git a/app/modules/malaysia/templates/malaysia/_i18n.html b/app/modules/malaysia/templates/malaysia/_i18n.html
index d6ef7a5..ce14e9d 100644
--- a/app/modules/malaysia/templates/malaysia/_i18n.html
+++ b/app/modules/malaysia/templates/malaysia/_i18n.html
@@ -32,4 +32,4 @@
-
+
diff --git a/app/modules/malaysia/templates/malaysia/rack_view.html b/app/modules/malaysia/templates/malaysia/rack_view.html
index 90eed1e..0c513f8 100644
--- a/app/modules/malaysia/templates/malaysia/rack_view.html
+++ b/app/modules/malaysia/templates/malaysia/rack_view.html
@@ -9,19 +9,79 @@
.rv-cell {
border:1px solid #cbd5e1; border-radius:6px; padding:5px;
background:#f8fafc; min-height:80px; display:flex; flex-direction:column; min-width:0;
+ cursor:grab; transition:box-shadow .12s, border-color .12s, background .12s;
}
.rv-cell.is-empty { background:#fff; }
+ .rv-cell.is-selected { border-color:#2563eb; box-shadow:0 0 0 2px #93c5fd inset; background:#eff6ff; }
+ .rv-cell.is-dragover { border-color:#2563eb; background:#dbeafe; }
+ .rv-cell.dragging { opacity:.45; }
.rv-cell-head {
display:flex; align-items:center; justify-content:space-between;
font-size:11px; font-weight:600; color:#475569; margin-bottom:4px;
border-bottom:1px solid #e2e8f0; padding-bottom:3px;
}
+ .rv-cell-head .rv-pick {
+ flex:0 0 auto; width:16px; height:16px; margin:0; cursor:pointer;
+ }
.rv-items { display:flex; flex-direction:column; gap:3px; }
.rv-item { font-size:11px; line-height:1.25; }
.rv-item .rv-name { font-weight:600; color:#0f172a; }
.rv-item .rv-calc { color:#475569; }
.rv-item .rv-qty { color:#2563eb; font-weight:600; }
.rv-empty-note { font-size:11px; color:#cbd5e1; }
+
+ /* ── 인쇄 미리보기 모달 ── */
+ .rvp-overlay {
+ display:none; position:fixed; inset:0; z-index:2000;
+ background:rgba(15,23,42,.55); overflow:auto; padding:24px;
+ }
+ .rvp-overlay.is-open { display:block; }
+ .rvp-toolbar {
+ position:sticky; top:0; display:flex; gap:8px; justify-content:flex-end;
+ align-items:center; margin-bottom:16px;
+ }
+ .rvp-toolbar .rvp-title { margin-right:auto; color:#fff; font-weight:700; font-size:16px; }
+ .rvp-pages { display:flex; flex-direction:column; gap:18px; align-items:center; }
+ /* 화면 미리보기: A4 가로 비율(297:210) 시트 */
+ .rvp-sheet {
+ background:#fff; width:min(100%, 1000px); aspect-ratio:297/210;
+ box-shadow:0 8px 24px rgba(0,0,0,.3); border-radius:4px;
+ display:flex; flex-direction:column; padding:4% 5%; box-sizing:border-box;
+ }
+ .rvp-cell-code {
+ font-weight:900; line-height:1; letter-spacing:.5px;
+ font-size:9vmin; color:#0f172a; border-bottom:4px solid #0f172a;
+ padding-bottom:2%; margin-bottom:3%; text-align:center;
+ }
+ .rvp-list { flex:1 1 auto; display:flex; flex-direction:column; justify-content:center; gap:3%; }
+ .rvp-line { text-align:center; }
+ .rvp-line .rvp-name { display:block; font-weight:900; font-size:6.5vmin; color:#0f172a; line-height:1.1; }
+ .rvp-line .rvp-calc { display:block; font-weight:800; font-size:5vmin; color:#1e293b; margin-top:1%; }
+ .rvp-empty { text-align:center; font-weight:900; font-size:7vmin; color:#94a3b8; margin:auto 0; }
+
+ /* ── 실제 인쇄 ── */
+ @media print {
+ @page { size: A4 landscape; margin: 8mm; }
+ body * { visibility: hidden !important; }
+ .rvp-overlay, .rvp-overlay * { visibility: visible !important; }
+ .rvp-overlay {
+ display:block !important; position:static !important; padding:0 !important;
+ background:#fff !important; overflow:visible !important;
+ }
+ .rvp-toolbar { display:none !important; }
+ .rvp-pages { gap:0 !important; }
+ .rvp-sheet {
+ width:100% !important; height:100vh !important; aspect-ratio:auto !important;
+ box-shadow:none !important; border-radius:0 !important; page-break-after:always;
+ padding:0 !important;
+ }
+ .rvp-sheet:last-child { page-break-after:auto; }
+ /* 인쇄 시 글씨 더 크게(멀리서도 보이게) — cm/vh 기준 */
+ .rvp-cell-code { font-size:3cm; border-bottom-width:6px; }
+ .rvp-line .rvp-name { font-size:2.4cm; }
+ .rvp-line .rvp-calc { font-size:1.6cm; }
+ .rvp-empty { font-size:2.5cm; }
+ }
{% endblock %}
@@ -46,7 +106,20 @@
-
+ {% if stocktake %}
+
+ 칸을 드래그해 다른 칸으로 옮기면 구성이 이동(채워진 칸끼리는 맞교환)됩니다. 칸 번호는 그대로입니다. 인쇄할 칸은 체크 후 인쇄하세요.
+
+
+
+ {% endif %}
+
+
+
+
{% for side in rack_layout %}
Side {{ side.side }}
@@ -55,19 +128,14 @@
{% for col in row.cols %}
{% for cell in col %}
{% set entries = rack_by_cell.get(cell, []) %}
-
+
- {{ cell }}
-
-
- {% for e in entries %}
-
- {{ e.item_name }}
- {{ "{:,}".format(e.units_per_box) }}개 × {{ e.box_count }}박스 = {{ "{:,}".format(e.total) }}
-
- {% endfor %}
- {% if not entries %}
—{% endif %}
+
{{ cell }}
+
+
{% endfor %}
{% endfor %}
@@ -77,5 +145,16 @@
{% endfor %}
+
+
+
+
+ 인쇄 미리보기
+
+
+
+
+
+
{% endblock %}
diff --git a/app/static/malaysia.js b/app/static/malaysia.js
index 27f553f..66261aa 100644
--- a/app/static/malaysia.js
+++ b/app/static/malaysia.js
@@ -20,6 +20,12 @@
"창고 랙 보기": "Rack View",
"창고 랙": "Warehouse Rack",
"랙 보기": "Rack View",
+ "인쇄 미리보기": "Print Preview",
+ "인쇄": "Print",
+ "저장": "Save",
+ "닫기": "Close",
+ "칸을 드래그해 다른 칸으로 옮기면 구성이 이동(채워진 칸끼리는 맞교환)됩니다. 칸 번호는 그대로입니다. 인쇄할 칸은 체크 후 인쇄하세요.":
+ "Drag a cell onto another to move its contents (filled cells swap). Cell numbers stay. Check cells, then print.",
"기준 재고조사": "Based on stocktake",
"표시할 재고조사가 없습니다.": "No stocktake to display.",
"박스": "box",
diff --git a/app/static/malaysia_rack_view.js b/app/static/malaysia_rack_view.js
new file mode 100644
index 0000000..7f513b8
--- /dev/null
+++ b/app/static/malaysia_rack_view.js
@@ -0,0 +1,220 @@
+/* 말레이시아 창고 랙 보기 — 드래그 재배치 + 저장 + 인쇄 미리보기.
+ *
+ * - 각 .rv-cell 의 data-entries(JSON)가 진실원천. 드래그 드롭으로 두 칸의
+ * 구성(entries)을 맞교환(빈 칸이면 이동). 칸 번호(cell)는 그대로.
+ * - 저장: 모든 칸의 현재 구성을 rk_cell/rk_sku/rk_upb/rk_box 병렬배열 hidden
+ * 으로 만들어 POST /malaysia/rack/save (서버 reposition_rack).
+ * - 인쇄: 체크한 칸을 칸마다 A4 가로 1장으로 미리보기 후 window.print().
+ * - 언어: localStorage('mys_lang'). 셀/미리보기 텍스트를 현재 언어로 렌더.
+ */
+(function () {
+ "use strict";
+
+ function lang() { return localStorage.getItem("mys_lang") === "en" ? "en" : "ko"; }
+ function t(ko, en) { return lang() === "en" ? en : ko; }
+ function fmt(n) { return Number(n || 0).toLocaleString(); }
+ function nameOf(e) { return (lang() === "en" ? e.en : e.ko) || e.sku; }
+
+ function getEntries(cell) {
+ try { return JSON.parse(cell.getAttribute("data-entries") || "[]"); }
+ catch (_) { return []; }
+ }
+ function setEntries(cell, arr) {
+ cell.setAttribute("data-entries", JSON.stringify(arr));
+ }
+
+ function renderCell(cell) {
+ var items = cell.querySelector(".rv-items");
+ if (!items) return;
+ var arr = getEntries(cell);
+ items.innerHTML = "";
+ if (!arr.length) {
+ cell.classList.add("is-empty");
+ var s = document.createElement("span");
+ s.className = "rv-empty-note";
+ s.textContent = "—";
+ items.appendChild(s);
+ return;
+ }
+ cell.classList.remove("is-empty");
+ arr.forEach(function (e) {
+ var total = (Number(e.upb) || 0) * (Number(e.box) || 0);
+ var wrap = document.createElement("div");
+ wrap.className = "rv-item";
+ var nm = document.createElement("span");
+ nm.className = "rv-name";
+ nm.textContent = nameOf(e);
+ var calc = document.createElement("span");
+ calc.className = "rv-calc";
+ calc.innerHTML = fmt(e.upb) + t("개", "ea") + " × " + fmt(e.box) + t("박스", "box")
+ + " =
" + fmt(total) + "";
+ wrap.appendChild(nm);
+ wrap.appendChild(document.createElement("br"));
+ wrap.appendChild(calc);
+ items.appendChild(wrap);
+ });
+ }
+ function renderAll() {
+ document.querySelectorAll(".rv-cell").forEach(renderCell);
+ }
+
+ // ── 드래그 재배치 ──
+ var dragSrc = null;
+ var dirty = false;
+ function markDirty() {
+ dirty = true;
+ var b = document.getElementById("rv-save-btn");
+ if (b) b.classList.add("is-dirty");
+ }
+ function cleanup() {
+ if (dragSrc) dragSrc.classList.remove("dragging");
+ dragSrc = null;
+ document.querySelectorAll(".rv-cell.is-dragover")
+ .forEach(function (c) { c.classList.remove("is-dragover"); });
+ }
+ function onDragStart(e) {
+ var cell = e.target.closest(".rv-cell");
+ if (!cell) return;
+ dragSrc = cell;
+ cell.classList.add("dragging");
+ e.dataTransfer.effectAllowed = "move";
+ try { e.dataTransfer.setData("text/plain", cell.getAttribute("data-cell")); } catch (_) {}
+ }
+ function onDragOver(e) {
+ if (!dragSrc) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ var cell = e.target.closest(".rv-cell");
+ if (cell && cell !== dragSrc) cell.classList.add("is-dragover");
+ }
+ function onDragLeave(e) {
+ var cell = e.target.closest(".rv-cell");
+ if (cell) cell.classList.remove("is-dragover");
+ }
+ function onDrop(e) {
+ e.preventDefault();
+ var dst = e.target.closest(".rv-cell");
+ if (!dst || !dragSrc || dst === dragSrc) { cleanup(); return; }
+ var a = getEntries(dragSrc);
+ var b = getEntries(dst);
+ setEntries(dragSrc, b);
+ setEntries(dst, a);
+ renderCell(dragSrc);
+ renderCell(dst);
+ markDirty();
+ cleanup();
+ }
+
+ // ── 인쇄 선택(체크박스) → 셀 강조 ──
+ function onChange(e) {
+ var cb = e.target;
+ if (!cb.classList || !cb.classList.contains("rv-pick")) return;
+ var cell = cb.closest(".rv-cell");
+ if (cell) cell.classList.toggle("is-selected", cb.checked);
+ }
+
+ // ── 저장 ──
+ function addHidden(parent, name, val) {
+ var i = document.createElement("input");
+ i.type = "hidden";
+ i.name = name;
+ i.value = val;
+ parent.appendChild(i);
+ }
+ function save() {
+ var fields = document.getElementById("rv-save-fields");
+ var form = document.getElementById("rv-save-form");
+ if (!fields || !form) return;
+ fields.innerHTML = "";
+ document.querySelectorAll(".rv-cell").forEach(function (cell) {
+ var cc = cell.getAttribute("data-cell");
+ getEntries(cell).forEach(function (e) {
+ addHidden(fields, "rk_cell", cc);
+ addHidden(fields, "rk_sku", e.sku);
+ addHidden(fields, "rk_upb", e.upb);
+ addHidden(fields, "rk_box", e.box);
+ });
+ });
+ dirty = false;
+ form.submit();
+ }
+
+ // ── 인쇄 미리보기 ──
+ function openPreview() {
+ var picked = [].slice.call(document.querySelectorAll(".rv-cell")).filter(function (c) {
+ var cb = c.querySelector(".rv-pick");
+ return cb && cb.checked;
+ });
+ if (!picked.length) {
+ alert(t("인쇄할 칸을 먼저 선택하세요.", "Select cells to print first."));
+ return;
+ }
+ var pages = document.getElementById("rvp-pages");
+ pages.innerHTML = "";
+ picked.forEach(function (cell) {
+ var arr = getEntries(cell);
+ var sheet = document.createElement("div");
+ sheet.className = "rvp-sheet";
+ var code = document.createElement("div");
+ code.className = "rvp-cell-code";
+ code.textContent = cell.getAttribute("data-cell");
+ sheet.appendChild(code);
+ var list = document.createElement("div");
+ list.className = "rvp-list";
+ if (!arr.length) {
+ var em = document.createElement("div");
+ em.className = "rvp-empty";
+ em.textContent = t("(비어 있음)", "(empty)");
+ list.appendChild(em);
+ } else {
+ arr.forEach(function (e) {
+ var total = (Number(e.upb) || 0) * (Number(e.box) || 0);
+ var line = document.createElement("div");
+ line.className = "rvp-line";
+ var nm = document.createElement("span");
+ nm.className = "rvp-name";
+ nm.textContent = nameOf(e);
+ var calc = document.createElement("span");
+ calc.className = "rvp-calc";
+ calc.textContent = fmt(e.upb) + t("개", "ea") + " × " + fmt(e.box)
+ + t("박스", "box") + " = " + fmt(total);
+ line.appendChild(nm);
+ line.appendChild(calc);
+ list.appendChild(line);
+ });
+ }
+ sheet.appendChild(list);
+ pages.appendChild(sheet);
+ });
+ document.getElementById("rvp-overlay").classList.add("is-open");
+ }
+ function closePreview() {
+ document.getElementById("rvp-overlay").classList.remove("is-open");
+ }
+
+ document.addEventListener("DOMContentLoaded", function () {
+ renderAll();
+ var board = document.getElementById("rv-board");
+ if (board) {
+ board.addEventListener("dragstart", onDragStart);
+ board.addEventListener("dragover", onDragOver);
+ board.addEventListener("dragleave", onDragLeave);
+ board.addEventListener("drop", onDrop);
+ board.addEventListener("dragend", cleanup);
+ board.addEventListener("change", onChange);
+ }
+ var sb = document.getElementById("rv-save-btn");
+ if (sb) sb.addEventListener("click", save);
+ var pb = document.getElementById("rv-print-btn");
+ if (pb) pb.addEventListener("click", openPreview);
+ var pc = document.getElementById("rvp-close");
+ if (pc) pc.addEventListener("click", closePreview);
+ var pp = document.getElementById("rvp-print");
+ if (pp) pp.addEventListener("click", function () { window.print(); });
+ var lt = document.getElementById("mys-lang-toggle");
+ if (lt) lt.addEventListener("click", function () { setTimeout(renderAll, 0); });
+ window.addEventListener("beforeunload", function (ev) {
+ if (dirty) { ev.preventDefault(); ev.returnValue = ""; }
+ });
+ });
+})();