From 6763b2af68144fa91614fe9c9064be792a6a8b21 Mon Sep 17 00:00:00 2001 From: king Date: Mon, 15 Jun 2026 18:06:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(malaysia):=20=EB=9E=99=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=9E=AC=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=C2=B7=EC=A0=80=EC=9E=A5=C2=B7=EC=9D=B8=EC=87=84=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 랙 보기에서 칸을 드래그해 다른 칸으로 구성을 옮긴다(채워진 칸끼리 맞교환, Side A↔B 가능). 칸 번호는 유지되고 수량 합계도 불변(위치만). 저장하면 그 배치로 기록되며, 재고조사 '이전값 불러오기'가 이 최종 위치를 불러온다. - db.reposition_rack: 칸(cell_code) 배치만 교체, daily_stocktake_line 보존, 확정본도 허용(취소만 불가). 위치 이동은 전산재고에 영향 없음 - db.previous_rack_entries: 정렬을 랙 저장시각(MAX created_at) 기준으로 통일 → '이전값 불러오기'가 랙 보기 최종 위치와 일치 - POST /malaysia/rack/save: latest_rack_stocktake 대상 reposition - rack_view.html + malaysia_rack_view.js: HTML5 드래그 이동/swap, 칸 체크 선택, 저장(병렬배열 직렬화), 인쇄 미리보기 모달 - 인쇄: 선택한 칸을 칸마다 A4 가로 1장, 굵은 대형 글씨(@media print), 미리보기 후 인쇄 - i18n 항목 추가, malaysia.js 캐시버스트 v20260615c Co-Authored-By: Claude Opus 4.8 --- app/modules/malaysia/db.py | 56 ++++- app/modules/malaysia/router.py | 26 +++ .../malaysia/templates/malaysia/_i18n.html | 2 +- .../templates/malaysia/rack_view.html | 103 +++++++- app/static/malaysia.js | 6 + app/static/malaysia_rack_view.js | 220 ++++++++++++++++++ 6 files changed, 399 insertions(+), 14 deletions(-) create mode 100644 app/static/malaysia_rack_view.js 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 = ""; } + }); + }); +})();