change(cupang): 화면 문구 "박스" → "상자" 통일

템플릿·라우터 메시지·페이지 제목과 seed/DDL 기본값(쿠팡박스→쿠팡상자)까지
표기를 맞췄다. URL(/box-calc)과 코드 식별자(box_name 등)는 그대로.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 16:00:09 +09:00
parent 1c1ca69feb
commit 4a260cb161
14 changed files with 140 additions and 140 deletions
+10 -10
View File
@@ -8,8 +8,8 @@
앱 계정(cupang_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다.
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
박스 수 계산은 서버에서 `store.compute_boxes` 로 재계산하여 저장한다.
클라이언트가 보낸 박스 수는 신뢰하지 않는다.
상자 수 계산은 서버에서 `store.compute_boxes` 로 재계산하여 저장한다.
클라이언트가 보낸 상자 수는 신뢰하지 않는다.
"""
from __future__ import annotations
@@ -136,7 +136,7 @@ class CupangDBStore:
return {"deleted": True, "deactivated": False}
# ════════════════════════════════════════════════════════════
# 박스 입수량 규칙 (cupang_box_rules)
# 상자 입수량 규칙 (cupang_box_rules)
# ════════════════════════════════════════════════════════════
def list_box_rules(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
where = "" if include_inactive else "WHERE active = TRUE"
@@ -153,7 +153,7 @@ class CupangDBStore:
product_code: str,
units_per_box: int,
product_name_snapshot: str = "",
box_name: str = "쿠팡박스",
box_name: str = "쿠팡상자",
memo: str = "",
) -> dict[str, Any]:
code = (product_code or "").strip()
@@ -161,7 +161,7 @@ class CupangDBStore:
raise ValueError("제품코드 필수")
upb = int(units_per_box)
if upb <= 0:
raise ValueError("박스당 입수량은 1 이상이어야 합니다.")
raise ValueError("상자당 입수량은 1 이상이어야 합니다.")
with self._pool.connection() as conn:
row = conn.execute(
"""
@@ -176,7 +176,7 @@ class CupangDBStore:
active = TRUE
RETURNING *
""",
(code, product_name_snapshot.strip(), (box_name or "쿠팡박스").strip(), upb, memo.strip()),
(code, product_name_snapshot.strip(), (box_name or "쿠팡상자").strip(), upb, memo.strip()),
).fetchone()
return self._rule_serialize(row)
@@ -196,8 +196,8 @@ class CupangDBStore:
raise KeyError(rule_id)
# ════════════════════════════════════════════════════════════
# 박스 계산 임시 저장 (cupang_box_calc_drafts)
# payload 는 화면 상태 스냅샷(JSONB). 박스 수 계산은 불러온 뒤
# 상자 계산 임시 저장 (cupang_box_calc_drafts)
# payload 는 화면 상태 스냅샷(JSONB). 상자 수 계산은 불러온 뒤
# 서버에서 다시 하므로 여기 값은 신뢰 대상이 아니다.
# ════════════════════════════════════════════════════════════
def list_box_calc_drafts(self, *, limit: int = 50) -> list[dict[str, Any]]:
@@ -348,7 +348,7 @@ class CupangDBStore:
) -> dict[str, Any]:
"""등록된 제품 수정(제품명/제품코드/쿠팡상품코드).
제품코드가 바뀌면 같은 코드로 연결돼 있던 박스 규칙(cupang_box_rules)의
제품코드가 바뀌면 같은 코드로 연결돼 있던 상자 규칙(cupang_box_rules)의
product_code 도 함께 옮긴다(출고 라인은 과거 기록이라 스냅샷 유지).
"""
name = (product_name or "").strip()
@@ -694,7 +694,7 @@ class CupangDBStore:
"worker": str(header.get("worker") or "").strip(),
"status": status,
"memo": str(header.get("memo") or "").strip(),
# 확정 당시 박스 구성(제품별/혼합). 화면 표시 전용이라 형태만 검사한다.
# 확정 당시 상자 구성(제품별/혼합). 화면 표시 전용이라 형태만 검사한다.
"box_plan": header.get("box_plan") if isinstance(header.get("box_plan"), list) else [],
}
+29 -29
View File
@@ -154,7 +154,7 @@ async def index(request: Request) -> HTMLResponse:
# 선택일의 묶음 — 달력과 같은 기준(출고일)
sel_shipments = [s for s in shipments if s.get("ship_date") == sel]
# 오른쪽 상세: 센터별 상품 목록·수량·박스 수·출고방식
# 오른쪽 상세: 센터별 상품 목록·수량·상자 수·출고방식
for s in sel_shipments:
full = store.get_shipment(shipment_id=s["id"])
lines = (full.get("lines") if full else []) or []
@@ -334,16 +334,16 @@ async def export_shipments_xlsx(request: Request, date: str = "") -> Any:
# ════════════════════════════════════════════════════════════
@router.get("/new")
async def new_form(request: Request) -> RedirectResponse:
"""신규 등록 폼은 없앴다. 출고 묶음은 박스 계산의 [분배 확정] 으로만 만든다.
"""신규 등록 폼은 없앴다. 출고 묶음은 상자 계산의 [분배 확정] 으로만 만든다.
예전 링크/북마크가 404 나지 않도록 박스 계산으로 보낸다.
예전 링크/북마크가 404 나지 않도록 상자 계산으로 보낸다.
"""
return RedirectResponse(url="/cupang/box-calc", status_code=303)
@router.get("/{shipment_id:int}", response_class=HTMLResponse)
async def detail(request: Request, shipment_id: int) -> HTMLResponse:
"""출고 묶음 보기 — 박스 계산 화면과 같은 3열 구성(읽기 전용).
"""출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성(읽기 전용).
수정 기능은 없앴다. 잘못 만들었으면 달력에서 삭제하고 다시 확정한다.
"""
@@ -611,7 +611,7 @@ async def center_delete(
# ════════════════════════════════════════════════════════════
# 박스 입수량 관리
# 상자 입수량 관리
# ════════════════════════════════════════════════════════════
@router.get("/box-rules", response_class=HTMLResponse)
async def box_rules_page(request: Request) -> HTMLResponse:
@@ -629,8 +629,8 @@ async def box_rules_page(request: Request) -> HTMLResponse:
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런 — 박스 입수량",
"page_subtitle": "제품코드별 쿠팡박스 1박스당 입수량 설정",
"page_title": "쿠팡 밀크런 — 상자 입수량",
"page_subtitle": "제품코드별 쿠팡상자 1상자당 입수량 설정",
"box_rules": store.list_box_rules(include_inactive=True),
"products": store.list_products(),
"search_enabled": bool((_itemcode(request)) and _itemcode(request).enabled),
@@ -644,7 +644,7 @@ async def box_rule_upsert(
product_code: str = Form(...),
units_per_box: int = Form(...),
product_name_snapshot: str = Form(""),
box_name: str = Form("쿠팡박스"),
box_name: str = Form("쿠팡상자"),
memo: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
@@ -681,7 +681,7 @@ async def box_rule_delete(
# ════════════════════════════════════════════════════════════
# 박스 계산기 — 제품명 + 수량 → 박스 수 / 남은 낱개
# 상자 계산기 — 제품명 + 수량 → 상자 수 / 남은 낱개
# 저장하지 않는 계산 전용 화면. 규칙은 cupang_box_rules 를 그대로 사용한다.
# ════════════════════════════════════════════════════════════
@router.get("/box-calc", response_class=HTMLResponse)
@@ -701,7 +701,7 @@ async def box_calc_page(request: Request) -> HTMLResponse:
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런 — 쿠팡 발주 업로드",
"page_subtitle": "쿠팡 발주 엑셀을 올리면 센터별 수량을 합산해 박스를 계산합니다.",
"page_subtitle": "쿠팡 발주 엑셀을 올리면 센터별 수량을 합산해 상자를 계산합니다.",
"box_rules": store.list_box_rules(),
# 센터 선택 드롭다운은 가나다순 (한글 음절은 코드포인트 순 = 가나다순)
"centers": sorted(store.list_centers(), key=lambda c: (c.get("name") or "")),
@@ -866,7 +866,7 @@ async def box_calc_upload(
if unknown_centers:
warnings.append("등록되지 않은 센터: " + ", ".join(sorted(unknown_centers)))
if no_rule:
warnings.append("박스 입수량 미설정: " + ", ".join(sorted(no_rule)))
warnings.append("상자 입수량 미설정: " + ", ".join(sorted(no_rule)))
uniq_dates = sorted(set(ship_dates))
@@ -927,7 +927,7 @@ async def box_calc_api(
payload: dict[str, Any] = Body(...),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
"""[{product_code, quantity}] → 제품별 박스 계산 + 박스명별 합계.
"""[{product_code, quantity}] → 제품별 상자 계산 + 상자명별 합계.
클라이언트 계산을 신뢰하지 않고 store.compute_boxes 로 서버에서 계산한다.
"""
@@ -943,9 +943,9 @@ async def box_calc_api(
def _compute_boxes(store: Any, raw_items: list[Any]) -> dict[str, Any]:
"""[{product_code, quantity}] → 제품별 박스 + 자투리 혼합 박스 + 합계.
"""[{product_code, quantity}] → 제품별 상자 + 자투리 혼합 상자 + 합계.
박스 계산 화면(API)과 출고 상세 보기가 같은 결과를 쓰도록 한 곳에 둔다.
상자 계산 화면(API)과 출고 상세 보기가 같은 결과를 쓰도록 한 곳에 둔다.
"""
from .store import compute_boxes # noqa: WPS433
@@ -1003,12 +1003,12 @@ def _compute_boxes(store: Any, raw_items: list[Any]) -> dict[str, Any]:
def _pack_leftovers(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""제품별 자투리(remainder_units)를 같은 박스명끼리 모아 혼합 박스에 담는다.
"""제품별 자투리(remainder_units)를 같은 상자명끼리 모아 혼합 상자에 담는다.
- 한 박스의 용량을 1 로 두고, 제품 1개가 차지하는 부피를 1/units_per_box 로 본다.
(예: 쿠팡 2호 = 8개들이 → 1개 = 1/8 박스)
- 같은 박스명끼리만 섞는다. 서로 다른 입수량이 섞여도 부피 합으로 정확히 계산된다.
- 한 제품의 자투리가 두 박스에 나뉘어 담기는 것은 허용(그래야 박스 수가 최소).
- 한 상자의 용량을 1 로 두고, 제품 1개가 차지하는 부피를 1/units_per_box 로 본다.
(예: 쿠팡 2호 = 8개들이 → 1개 = 1/8 상자)
- 같은 상자명끼리만 섞는다. 서로 다른 입수량이 섞여도 부피 합으로 정확히 계산된다.
- 한 제품의 자투리가 두 상자에 나뉘어 담기는 것은 허용(그래야 상자 수가 최소).
- 오차 없이 계산하려고 float 대신 Fraction 을 쓴다.
"""
groups: dict[str, list[dict[str, Any]]] = {}
@@ -1019,7 +1019,7 @@ def _pack_leftovers(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for box_name in sorted(groups):
# 자투리가 많은 제품부터 담아 박스 안 품목 수를 줄인다.
# 자투리가 많은 제품부터 담아 상자 안 품목 수를 줄인다.
items = sorted(groups[box_name], key=lambda r: -r["remainder_units"])
boxes: list[dict[str, Any]] = []
cur: list[dict[str, Any]] = []
@@ -1030,7 +1030,7 @@ def _pack_leftovers(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
left = int(r["remainder_units"])
while left > 0:
take = min(left, int(free / unit))
if take == 0: # 남은 자리 없음 → 새 박스
if take == 0: # 남은 자리 없음 → 새 상자
boxes.append({"items": cur, "fill_percent": float(round((1 - free) * 100, 1))})
cur, free = [], Fraction(1)
continue
@@ -1245,9 +1245,9 @@ async def product_delete(
# 분배 확정 — 센터별 출고 묶음 생성
# ════════════════════════════════════════════════════════════
def _clean_box_plan(raw: Any) -> list[dict[str, Any]]:
"""화면이 보낸 박스 구성을 표시용으로만 정리해서 저장한다.
"""화면이 보낸 상자 구성을 표시용으로만 정리해서 저장한다.
수량·박스 수는 라인(제품 합계)이 정답이고, 이 값은 "어느 상자에 무엇이
수량·상자 수는 라인(제품 합계)이 정답이고, 이 값은 "어느 상자에 무엇이
담겼는지"를 보여주기 위한 스냅샷이다.
"""
if not isinstance(raw, list):
@@ -1305,7 +1305,7 @@ async def box_calc_confirm(
) -> JSONResponse:
"""{ship_date, centers:[{center_id, ship_method, boxes, items:[...]}]} → 센터마다 출고 묶음 1건 생성.
화면에서 보낸 박스 수는 요약 표시용이고, 라인의 박스 수는 저장 시
화면에서 보낸 상자 수는 요약 표시용이고, 라인의 상자 수는 저장 시
서버(store.compute_boxes)가 수량과 입수량으로 다시 계산한다.
"""
from .store import SHIP_METHODS as _METHODS # noqa: WPS433
@@ -1345,7 +1345,7 @@ async def box_calc_confirm(
status_code=400, detail=f"{center['name']} 의 출고방식을 선택하세요."
)
# 같은 제품이 여러 박스로 나뉘어 담겼을 수 있으므로 제품코드로 합친다.
# 같은 제품이 여러 상자로 나뉘어 담겼을 수 있으므로 제품코드로 합친다.
merged: dict[str, int] = {}
for it in raw.get("items") or []:
if not isinstance(it, dict):
@@ -1382,10 +1382,10 @@ async def box_calc_confirm(
except (TypeError, ValueError):
boxes = 0
pieces = sum(merged.values())
# 화면에서 고른 상자 종류별 박스 수(예: "쿠팡상자41"). 출고리스트 엑셀의 "출고" 칸.
# 화면에서 고른 상자 종류별 상자 수(예: "쿠팡상자41"). 출고리스트 엑셀의 "출고" 칸.
summary = str(raw.get("box_summary") or "").strip()
if not summary:
summary = f"{boxes}박스 · {pieces}" if boxes else f"{pieces}"
summary = f"{boxes}상자 · {pieces}" if boxes else f"{pieces}"
plans.append(
{
@@ -1417,7 +1417,7 @@ async def box_calc_confirm(
"outbound_summary": plan["summary"],
"worker": worker,
"status": "출고준비",
"memo": "박스 계산에서 분배 확정",
"memo": "상자 계산에서 분배 확정",
"box_plan": plan["box_plan"],
},
lines=plan["lines"],
@@ -1438,7 +1438,7 @@ async def box_calc_confirm(
# ════════════════════════════════════════════════════════════
# 박스 계산 임시 저장 (화면 상태 스냅샷)
# 상자 계산 임시 저장 (화면 상태 스냅샷)
# ════════════════════════════════════════════════════════════
@router.get("/api/box-calc/drafts")
async def box_calc_drafts_list(
+1 -1
View File
@@ -58,7 +58,7 @@ DEFAULT_CENTERS: tuple[str, ...] = (
def compute_boxes(quantity: int, units_per_box: int | None) -> dict[str, Any]:
"""수량 + 박스당 입수량으로 필요한 박스 수를 계산한다.
"""수량 + 상자당 입수량으로 필요한 상자 수를 계산한다.
클라이언트 계산을 신뢰하지 않고 서버에서 이 함수로 재계산한다.
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901o" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -16,8 +16,8 @@
{% if not box_rules %}
<div class="erp-card cpg-form-card">
<p class="erp-muted">
등록된 박스 입수량 규칙이 없습니다.
<a href="/cupang/box-rules">박스 입수량 설정</a>에서 먼저 제품별 입수량을 등록하세요.
등록된 상자 입수량 규칙이 없습니다.
<a href="/cupang/box-rules">상자 입수량 설정</a>에서 먼저 제품별 입수량을 등록하세요.
</p>
</div>
{% else %}
@@ -30,7 +30,7 @@
<div class="cpg-calc3">
<!--박스 계산 — 제품명 + 수량만 입력 -->
<!--상자 계산 — 제품명 + 수량만 입력 -->
<div class="erp-card cpg-form-card cpg-calc-card">
<div class="cpg-card-head">
<h2>① 쿠팡 발주 업로드</h2>
@@ -75,10 +75,10 @@
</div>
</div>
<!--박스 요약 — 센터에 배분하고 남은 수량이 실시간 반영 -->
<!--상자 요약 — 센터에 배분하고 남은 수량이 실시간 반영 -->
<div class="erp-card cpg-form-card cpg-calc-sum">
<div class="cpg-card-head">
<h2>박스 요약</h2>
<h2>상자 요약</h2>
<span class="erp-muted">카드를 ③ 센터로 끌어다 놓거나, 카드를 클릭해 담습니다.</span>
</div>
@@ -88,10 +88,10 @@
<div class="cpg-sum-kpis" id="cpg-sum-kpis"></div>
<div class="cpg-sum-scroll">
<h3 class="cpg-sum-h3">제품별 박스 <span class="erp-muted">잔여 기준</span></h3>
<h3 class="cpg-sum-h3">제품별 상자 <span class="erp-muted">잔여 기준</span></h3>
<div class="cpg-sum-prods" id="cpg-sum-prods"></div>
<h3 class="cpg-sum-h3">자투리 혼합 박스 <span class="erp-muted" id="cpg-sum-mix-note"></span></h3>
<h3 class="cpg-sum-h3">자투리 혼합 상자 <span class="erp-muted" id="cpg-sum-mix-note"></span></h3>
<div class="cpg-sum-boxes" id="cpg-sum-boxes"></div>
</div>
</div>
@@ -103,7 +103,7 @@
<h2>③ 센터 분배</h2>
<span class="erp-muted">담을 센터를 골라 추가하세요.</span>
<button type="button" class="erp-btn erp-btn-primary cpg-confirm-btn" id="cpg-confirm" disabled
title="모든 박스를 배분하고 센터마다 출고방식을 고르면 활성화됩니다.">분배 확정</button>
title="모든 상자를 배분하고 센터마다 출고방식을 고르면 활성화됩니다.">분배 확정</button>
</div>
{% if not centers %}
@@ -118,7 +118,7 @@
</div><!-- /cpg-calc3 -->
<!-- 담을 박스 수량 입력 대화상자 -->
<!-- 담을 상자 수량 입력 대화상자 -->
<div class="cpg-modal" id="cpg-dlg" hidden>
<div class="cpg-modal-back" data-dlg-close></div>
<div class="cpg-modal-box" role="dialog" aria-modal="true" aria-labelledby="cpg-dlg-title">
@@ -126,7 +126,7 @@
<p class="cpg-dlg-item" id="cpg-dlg-item"></p>
<div class="erp-field"><span>센터</span>
<div class="cpg-dlg-centers" id="cpg-dlg-centers" role="group" aria-label="담을 센터"></div></div>
<label class="erp-field"><span>박스 수량</span>
<label class="erp-field"><span>상자 수량</span>
<input class="erp-input" type="number" id="cpg-dlg-qty" min="1" step="1" /></label>
<p class="erp-muted" id="cpg-dlg-hint"></p>
<div class="erp-page-actions cpg-dlg-actions">
@@ -195,7 +195,7 @@
<script type="application/json" id="cpg-calc-rules">{{ box_rules | tojson }}</script>
<script type="application/json" id="cpg-centers">{{ centers | tojson }}</script>
<script>
// 박스 계산 3열 화면.
// 상자 계산 3열 화면.
// ① 제품명·수량 입력 → 서버(POST /cupang/api/box-calc)에서 계산
// ② 계산 결과 요약 — 드래그(또는 클릭) 소스
// ③ 선택한 센터에만 분배. 담은 수량만큼 ② 의 잔여가 즉시 줄어든다.
@@ -243,8 +243,8 @@
// ── 상태 ─────────────────────────────────────────
var calc = { results: [], mixes: [] }; // 서버 계산 결과
var units = {}; // key -> 박스 1개당 상품 수
var contents = {}; // key -> 혼합 박스 내용물 [{product_name, quantity}]
var units = {}; // key -> 상자 1개당 상품 수
var contents = {}; // key -> 혼합 상자 내용물 [{product_name, quantity}]
var labels = {}; // key -> 표시 이름
var openCenters = []; // ③ 에 추가한 센터 id (문자열)
var alloc = {}; // centerId -> [{id, key, count}]
@@ -255,7 +255,7 @@
function prodKey(code) { return "p:" + code; }
function mixKey(boxName, idx) { return "m:" + boxName + "#" + idx; }
// 자투리 혼합 박스에 쓸 상자 종류 — 요약 카드에서 고른다(표시용).
// 자투리 혼합 상자에 쓸 상자 종류 — 요약 카드에서 고른다(표시용).
var MIX_TYPES = ["쿠팡상자", "3호상자", "6호상자"];
var mixType = {}; // mixKey -> 고른 상자 종류
var mixOwner = {}; // mixKey -> 센터명(업로드로 만들어진 경우)
@@ -360,8 +360,8 @@
}
// ── 쿠팡 발주 엑셀 업로드 ─────────────────────────
// 업로드 → 센터별 수량 합산(서버) → 센터마다 박스 계산 → ②③ 자동 채움.
// 박스는 센터별로 포장하므로 계산도 센터 단위로 한다(자투리 혼합 박스 포함).
// 업로드 → 센터별 수량 합산(서버) → 센터마다 상자 계산 → ②③ 자동 채움.
// 상자는 센터별로 포장하므로 계산도 센터 단위로 한다(자투리 혼합 상자 포함).
var poFile = document.getElementById("cpg-po-file");
var poMsg = document.getElementById("cpg-po-msg");
var poResult = document.getElementById("cpg-po-result");
@@ -412,7 +412,7 @@
return '<button type="button" class="cpg-date-tab' + (d === activeDate ? " is-active" : "") + '"' +
' data-date="' + esc(d) + '">' +
'<strong>' + esc(d || "날짜 미확인") + "</strong>" +
'<span>센터 ' + cn + "곳 · " + bx + "박스</span>" +
'<span>센터 ' + cn + "곳 · " + bx + "상자</span>" +
"</button>";
}).join("");
}
@@ -464,8 +464,8 @@
});
}
// 센터별 계산 결과를 하나의 ② 요약으로 합치고, 각 박스를 그 센터에 자동 배분한다.
// (한 출고일 안에서만 합친다 — 박스는 센터 단위로 포장)
// 센터별 계산 결과를 하나의 ② 요약으로 합치고, 각 상자를 그 센터에 자동 배분한다.
// (한 출고일 안에서만 합친다 — 상자는 센터 단위로 포장)
function buildDataset(groups, perCenter) {
var mergedProds = {}; // code -> 합산 결과
var mergedMixes = {}; // box_name -> {box_name, boxes:[]}
@@ -603,7 +603,7 @@
return;
}
// 날짜 × 센터 단위로 박스 계산 (박스는 센터별로 포장되므로)
// 날짜 × 센터 단위로 상자 계산 (상자는 센터별로 포장되므로)
var jobs = [];
dateGroups.forEach(function (g) {
g.centers.forEach(function (c) {
@@ -663,7 +663,7 @@
var hit = calc.results.filter(function (r) { return r.product_code === code; })[0];
return hit ? hit.full_boxes : 0;
}
return 1; // 혼합 박스는 1장 = 1박스
return 1; // 혼합 상자는 1장 = 1상자
}
function remainFor(key) { return Math.max(0, totalFor(key) - allocatedFor(key)); }
@@ -736,7 +736,7 @@
});
var assigned = (totalProd + totalMix) - (remainProd + remainMix);
// 모든 박스를 센터에 배분하면 "센터 배분" 칸에 전체 완료 도장을 찍는다.
// 모든 상자를 센터에 배분하면 "센터 배분" 칸에 전체 완료 도장을 찍는다.
var totalBoxes = totalProd + totalMix;
var allDone = totalBoxes > 0 && (remainProd + remainMix) === 0;
var allStamp = "";
@@ -747,9 +747,9 @@
allStamped = allDone;
sumKpis.innerHTML =
kpi("총 박스", totalBoxes + "박스", "제품별 " + totalProd + " + 혼합 " + totalMix) +
kpi("센터 배분", assigned + "박스", "③ 에 담긴 수량", allStamp) +
kpi("미배분 잔여", (remainProd + remainMix) + "박스", "자투리 " + leftoverUnits + "개 포함");
kpi("총 상자", totalBoxes + "상자", "제품별 " + totalProd + " + 혼합 " + totalMix) +
kpi("센터 배분", assigned + "상자", "③ 에 담긴 수량", allStamp) +
kpi("미배분 잔여", (remainProd + remainMix) + "상자", "자투리 " + leftoverUnits + "개 포함");
var ph = "";
calc.results.forEach(function (r) {
@@ -764,8 +764,8 @@
(remain ? "" : stampHtml(key)) +
"</div>" +
'<div class="cpg-sum-prod-nums">' +
"<strong>" + remain + "박스</strong>" +
'<span class="erp-muted">' + (remain * r.units_per_box) + "개 · 총 " + r.full_boxes + "박스" +
"<strong>" + remain + "상자</strong>" +
'<span class="erp-muted">' + (remain * r.units_per_box) + "개 · 총 " + r.full_boxes + "상자" +
(used ? " · 배분 " + used : "") + "</span>" +
(r.remainder_units
? '<span class="cpg-sum-left">자투리 ' + r.remainder_units + "개</span>"
@@ -779,7 +779,7 @@
sumMixNote.textContent = "— 자투리 없음";
sumBoxes.innerHTML = '<p class="erp-muted">남는 낱개가 없습니다.</p>';
} else {
sumMixNote.textContent = "— 1장 = 1박스";
sumMixNote.textContent = "— 1장 = 1상자";
var bh = "", n = 0;
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
@@ -809,14 +809,14 @@
}
}
// 혼합 박스는 안에 무엇이 몇 개 들어가는지 트리로 펼쳐 보여준다.
// 혼합 상자는 안에 무엇이 몇 개 들어가는지 트리로 펼쳐 보여준다.
function treeHtml(entry) {
var list = contents[entry.key];
if (!list || !list.length) return "";
return '<ul class="cpg-dist-tree">' + list.map(function (it) {
return '<li><span class="cpg-tree-name">' + esc(it.product_name) + "</span>" +
'<span class="cpg-tree-qty">' + (it.quantity * entry.count) + "개</span>" +
(entry.count > 1 ? '<span class="cpg-tree-per">(' + it.quantity + "개 × " + entry.count + "박스)</span>" : "") +
(entry.count > 1 ? '<span class="cpg-tree-per">(' + it.quantity + "개 × " + entry.count + "상자)</span>" : "") +
"</li>";
}).join("") + "</ul>";
}
@@ -838,7 +838,7 @@
'<div class="cpg-dist-line">' +
'<span class="cpg-dist-name">' + esc(labels[a.key] || a.key) + "</span>" +
'<input class="erp-input cpg-dist-qty" type="number" min="1" max="999" step="1" inputmode="numeric" value="' + a.count + '" />' +
'<span class="cpg-dist-unit">박스 · ' + (a.count * per) + "개</span>" +
'<span class="cpg-dist-unit">상자 · ' + (a.count * per) + "개</span>" +
'<button type="button" class="erp-btn erp-btn-danger cpg-dist-del" title="빼기" aria-label="빼기">✕</button>' +
"</div>" +
treeHtml(a) +
@@ -855,14 +855,14 @@
'<option value="파렛트"' + (method === "파렛트" ? " selected" : "") + '>파렛트</option>' +
"</select>" +
'<span class="cpg-dist-sum">' +
'<span class="erp-badge erp-badge-neutral">' + boxes + "박스</span>" +
'<span class="erp-badge erp-badge-neutral">' + boxes + "상자</span>" +
'<span class="erp-badge erp-badge-neutral">' + pieces + "개</span>" +
'<button type="button" class="erp-btn erp-btn-outline cpg-dist-close" title="센터 빼기" aria-label="센터 빼기">✕</button>' +
"</span>" +
"</div>" +
(rows.length
? '<ul class="cpg-dist-items">' + items + "</ul>"
: '<p class="cpg-dist-hint">여기로 박스를 끌어다 놓기</p>') +
: '<p class="cpg-dist-hint">여기로 상자를 끌어다 놓기</p>') +
"</div>";
});
distList.innerHTML = html;
@@ -875,15 +875,15 @@
function openDialog(key, centerId) {
if (!openCenters.length) { msg.textContent = "먼저 ③ 에서 센터를 추가하세요."; return; }
var remain = remainFor(key);
if (remain <= 0) { msg.textContent = "남은 박스가 없습니다."; return; }
if (remain <= 0) { msg.textContent = "남은 상자가 없습니다."; return; }
pending = { key: key };
dlgItem.textContent = (labels[key] || key) + " — 잔여 " + remain + "박스 (" + (remain * (units[key] || 0)) + "개)";
dlgItem.textContent = (labels[key] || key) + " — 잔여 " + remain + "상자 (" + (remain * (units[key] || 0)) + "개)";
dlgCenterId = String(centerId != null ? centerId : openCenters[0]);
renderDialogCenters();
dlgQty.max = remain;
dlgQty.value = key.indexOf("m:") === 0 ? 1 : remain;
dlgHint.textContent = "1박스 = " + (units[key] || 0) + "개 · 최대 " + remain + "박스";
dlgHint.textContent = "1상자 = " + (units[key] || 0) + "개 · 최대 " + remain + "상자";
dlg.hidden = false;
dlgQty.focus();
dlgQty.select();
@@ -916,7 +916,7 @@
closeDialog();
render();
msg.textContent = got
? centerName(cid) + " ← " + (labels[key] || key) + " " + got + "박스"
? centerName(cid) + " ← " + (labels[key] || key) + " " + got + "상자"
: "담을 수량이 없습니다.";
}
@@ -1069,7 +1069,7 @@
var max = entry.count + remainFor(entry.key);
if (want > max) { want = max; e.target.value = max; }
entry.count = want;
li.querySelector(".cpg-dist-unit").textContent = "박스 · " + (want * (units[entry.key] || 0)) + "개";
li.querySelector(".cpg-dist-unit").textContent = "상자 · " + (want * (units[entry.key] || 0)) + "개";
var tree = li.querySelector(".cpg-dist-tree");
if (tree) tree.outerHTML = treeHtml(entry);
renderSummary();
@@ -1105,7 +1105,7 @@
pieces += a.count * (units[a.key] || 0);
});
var badges = zone.querySelectorAll(".cpg-dist-sum .erp-badge");
if (badges[0]) badges[0].textContent = boxes + "박스";
if (badges[0]) badges[0].textContent = boxes + "상자";
if (badges[1]) badges[1].textContent = pieces + "개";
}
@@ -1203,9 +1203,9 @@
function confirmBlockReason() {
var t = boxTotals();
if (!t.total) return "먼저 ① 에서 계산하세요.";
if (t.remain) return "아직 " + t.remain + "박스가 남았습니다.";
if (t.remain) return "아직 " + t.remain + "상자가 남았습니다.";
var cids = filledCenters();
if (!cids.length) return "센터에 담긴 박스가 없습니다.";
if (!cids.length) return "센터에 담긴 상자가 없습니다.";
var missing = cids.filter(function (cid) { return !methods[String(cid)]; });
if (missing.length) return "출고방식 미선택: " + missing.map(centerName).join(", ");
return "";
@@ -1218,7 +1218,7 @@
confirmBtn.title = why || "출고일자를 골라 출고 묶음을 만듭니다.";
}
// 센터별 전송 payload — 혼합 박스는 내용물 제품으로 풀어서 담는다.
// 센터별 전송 payload — 혼합 상자는 내용물 제품으로 풀어서 담는다.
function buildPlans() {
return filledCenters().map(function (cid) {
var rows = alloc[cid] || [];
@@ -1234,7 +1234,7 @@
});
}
});
// 출고 엑셀 "출고" 칸 — 상자 종류별 박스 수 (예: "쿠팡상자, 41개")
// 출고 엑셀 "출고" 칸 — 상자 종류별 상자 수 (예: "쿠팡상자, 41개")
// 종류가 여럿이면 " / " 로 잇는다 (예: "쿠팡상자, 41개 / 3호상자, 2개")
var byType = {};
var order = [];
@@ -1329,7 +1329,7 @@
var pieces = pl.items.reduce(function (n, it) { return n + it.quantity; }, 0);
return '<div class="cpg-cfm-row">' +
'<span class="cpg-cfm-cname">' + esc(centerName(pl.center_id)) + "</span>" +
'<span class="cpg-cfm-cmeta">' + esc(pl.ship_method) + " · " + pl.boxes + "박스 · " + pieces + "개</span>" +
'<span class="cpg-cfm-cmeta">' + esc(pl.ship_method) + " · " + pl.boxes + "상자 · " + pieces + "개</span>" +
"</div>";
}).join("");
renderCal();
@@ -1565,7 +1565,7 @@
if (!items.length) addRow("", 0);
draftName.value = d.title || "";
closeDraft();
// 박스 수는 서버에서 다시 계산한 뒤 분배 내역을 얹는다.
// 상자 수는 서버에서 다시 계산한 뒤 분배 내역을 얹는다.
run(snap);
})
.catch(function () { draftMsg.textContent = "불러오기 실패 — 다시 시도하세요."; });
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901o" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -14,7 +14,7 @@
<!-- 왼쪽: 추가/수정 (product_code UNIQUE → upsert) -->
<div class="erp-card cpg-form-card cpg-brule-add">
<div class="cpg-card-head">
<h2>박스 입수량 추가 / 수정</h2>
<h2>상자 입수량 추가 / 수정</h2>
<span class="erp-muted">같은 제품코드는 덮어씁니다.</span>
</div>
<form method="post" action="/cupang/box-rules">
@@ -32,9 +32,9 @@
<input class="erp-input cpg-brule-code" type="text" name="product_code" id="brule-code" required placeholder="자동" /></label>
</div>
<div class="cpg-brule-row">
<label class="erp-field"><span>박스이름</span>
<input class="erp-input cpg-brule-box" type="text" name="box_name" value="쿠팡박스" /></label>
<label class="erp-field"><span>박스당 입수량 *</span>
<label class="erp-field"><span>상자이름</span>
<input class="erp-input cpg-brule-box" type="text" name="box_name" value="쿠팡상자" /></label>
<label class="erp-field"><span>상자당 입수량 *</span>
<span class="cpg-upb-wrap">
<input class="erp-input cpg-brule-upb" type="number" name="units_per_box" min="1" required />
<span class="cpg-upb-unit"></span>
@@ -74,7 +74,7 @@
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr><th>제품명</th><th>제품코드</th><th>박스</th><th>입수량</th><th>메모</th><th>동작</th></tr>
<tr><th>제품명</th><th>제품코드</th><th>상자</th><th>입수량</th><th>메모</th><th>동작</th></tr>
</thead>
<tbody>
{% for r in box_rules %}
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901o" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901o" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -12,7 +12,7 @@
<span class="cpg-settings-btns">
<a class="erp-btn erp-btn-outline" href="/cupang/products">제품명 설정</a>
<a class="erp-btn erp-btn-outline" href="/cupang/centers">입고센터 관리</a>
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">박스 입수량 설정</a>
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">상자 입수량 설정</a>
</span>
</div>
<div class="cpg-actions-spacer"></div>
@@ -97,7 +97,7 @@
<a class="cpg-ship-name" href="/cupang/{{ s.id }}">{{ s.center_name_snapshot or '센터 미지정' }}</a>
<span class="erp-badge erp-badge-neutral">{{ s.ship_method }}</span>
<span class="cpg-dist-sum">
<span class="erp-badge erp-badge-neutral">{{ s.total_boxes }}박스</span>
<span class="erp-badge erp-badge-neutral">{{ s.total_boxes }}상자</span>
<span class="erp-badge erp-badge-neutral">{{ s.total_qty }}개</span>
<button type="button" class="cpg-icon-btn is-danger" title="이 출고 삭제"
data-del="{{ s.id }}" data-name="{{ s.center_name_snapshot }}">삭제</button>
@@ -110,7 +110,7 @@
<li class="cpg-dist-item{% if e.kind == 'mix' %} is-mix{% endif %}">
<div class="cpg-dist-line">
<span class="cpg-dist-name">{{ e.name }}</span>
<span class="cpg-dist-unit">{{ e.count }}박스 · {{ e.quantity }}개</span>
<span class="cpg-dist-unit">{{ e.count }}상자 · {{ e.quantity }}개</span>
</div>
{% if e.kind == 'mix' and e['items'] %}
<ul class="cpg-dist-tree">
@@ -123,12 +123,12 @@
</li>
{% endfor %}
{% else %}
{# 예전에 만든 출고(박스 구성 미저장) — 제품 합계만 표시 #}
{# 예전에 만든 출고(상자 구성 미저장) — 제품 합계만 표시 #}
{% for it in s['items'] %}
<li class="cpg-dist-item">
<div class="cpg-dist-line">
<span class="cpg-dist-name">{{ it.name }}</span>
<span class="cpg-dist-unit">{{ it.boxes }}박스 · {{ it.qty }}개</span>
<span class="cpg-dist-unit">{{ it.boxes }}상자 · {{ it.qty }}개</span>
</div>
</li>
{% endfor %}
@@ -141,7 +141,7 @@
</ul>
{% else %}
<p class="erp-muted cpg-empty">선택한 날짜의 출고 묶음이 없습니다.
<a href="/cupang/box-calc">박스 계산에서 등록</a></p>
<a href="/cupang/box-calc">상자 계산에서 등록</a></p>
{% endif %}
</div>
</div>
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901o" />{% endblock %}
{% block content %}
<section class="cpg">
+13 -13
View File
@@ -1,9 +1,9 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901o" />{% endblock %}
{% block content %}
{# 출고 묶음 보기 — 박스 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}
{# 출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}
<section class="cpg">
<div class="erp-page-actions">
@@ -44,18 +44,18 @@
</div>
</div>
<!--박스 요약 -->
<!--상자 요약 -->
<div class="erp-card cpg-form-card cpg-calc-sum">
<div class="cpg-card-head">
<h2>박스 요약</h2>
<h2>상자 요약</h2>
<span class="erp-muted">확정 당시 수량 기준으로 다시 계산한 값</span>
</div>
<div id="cpg-sum-body">
<div class="cpg-sum-kpis">
<div class="cpg-kpi">
<span class="cpg-kpi-label">박스</span>
<strong class="cpg-kpi-value">{{ calc.grand_total_boxes }}박스</strong>
<span class="cpg-kpi-label">상자</span>
<strong class="cpg-kpi-value">{{ calc.grand_total_boxes }}상자</strong>
<span class="cpg-kpi-hint">제품별 + 혼합</span>
</div>
<div class="cpg-kpi">
@@ -71,7 +71,7 @@
</div>
<div class="cpg-sum-scroll">
<h3 class="cpg-sum-h3">제품별 박스</h3>
<h3 class="cpg-sum-h3">제품별 상자</h3>
<div class="cpg-sum-prods">
{% for r in calc.results if r.configured %}
<div class="cpg-sum-prod is-done">
@@ -79,7 +79,7 @@
<span class="cpg-sum-prod-name">{{ r.product_name }}</span>
</div>
<div class="cpg-sum-prod-nums">
<strong>{{ r.full_boxes }}박스</strong>
<strong>{{ r.full_boxes }}상자</strong>
<span class="erp-muted">{{ r.quantity }}개 · 입수량 {{ r.units_per_box }}</span>
{% if r.remainder_units %}
<span class="cpg-sum-left">자투리 {{ r.remainder_units }}개</span>
@@ -91,14 +91,14 @@
{% endfor %}
{% set unconfigured = calc.results | rejectattr('configured') | list %}
{% if unconfigured %}
<p class="erp-muted">박스 입수량 미설정:
<p class="erp-muted">상자 입수량 미설정:
{% for r in unconfigured %}{{ r.product_name }}{% if not loop.last %}, {% endif %}{% endfor %}
</p>
{% endif %}
</div>
<h3 class="cpg-sum-h3">자투리 혼합 박스
<span class="erp-muted">{% if not calc.mixes %}— 자투리 없음{% else %}— 1장 = 1박스{% endif %}</span>
<h3 class="cpg-sum-h3">자투리 혼합 상자
<span class="erp-muted">{% if not calc.mixes %}— 자투리 없음{% else %}— 1장 = 1상자{% endif %}</span>
</h3>
<div class="cpg-sum-boxes">
{% for m in calc.mixes %}
@@ -135,7 +135,7 @@
<strong>{{ shipment.center_name_snapshot or '센터 미지정' }}</strong>
<span class="erp-badge erp-badge-neutral">{{ shipment.ship_method }}</span>
<span class="cpg-dist-sum">
<span class="erp-badge erp-badge-neutral">{{ calc.grand_total_boxes }}박스</span>
<span class="erp-badge erp-badge-neutral">{{ calc.grand_total_boxes }}상자</span>
<span class="erp-badge erp-badge-neutral">{{ total_qty }}개</span>
</span>
</div>
@@ -145,7 +145,7 @@
<li class="cpg-dist-item{% if e.kind == 'mix' %} is-mix{% endif %}">
<div class="cpg-dist-line">
<span class="cpg-dist-name">{{ e.name }}</span>
<span class="cpg-dist-unit">{{ e.count }}박스 · {{ e.quantity }}개</span>
<span class="cpg-dist-unit">{{ e.count }}상자 · {{ e.quantity }}개</span>
</div>
{% if e.kind == 'mix' and e['items'] %}
<ul class="cpg-dist-tree">