feat(cupang): 출고 등록 경로를 박스 계산으로 일원화, 달력 표시 정리

출고 묶음을 만드는 길이 둘(신규 등록 폼 / 박스 계산 분배 확정)이라 헷갈렸다.
폼 경로를 없애고 [+ 신규 등록] 이 박스 계산을 열게 한다. 중복이던 오른쪽
[박스 계산] 버튼은 제거. /cupang/new 는 북마크가 깨지지 않게 박스 계산으로
리다이렉트하고 POST /new(생성)는 삭제했다. 기존 묶음 수정은 그대로 동작한다.

달력 칸은 작성/출고/입고 세 배지를 늘어놓아 읽기 어려웠다. 출고 기준으로
"출고 N건 · 센터 M곳"만 남긴다. 오른쪽 상세도 같은 기준(출고일)으로 맞추고
센터별 출고방식·총 박스/개수와 상품별 수량·박스를 카드 안에 펼친다. 품목이
바로 보이므로 hover 툴팁은 제거.

임시 저장 기본 제목은 "2026.08.31(월) 오후 04시 50분" 형식으로.
This commit is contained in:
2026-08-31 16:58:39 +09:00
parent 7f879d279a
commit 7a01c140b0
10 changed files with 95 additions and 138 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
- 반품관리
- 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등)
- 개인경비 (`app/modules/expense/`, `expense_db`)
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용. 출고 묶음은 **박스 계산 화면의 [분배 확정] 으로만** 만든다(신규 등록 폼 없음, `/cupang/new` 는 박스 계산으로 리다이렉트). 확정 조건: 미배분 박스 0 + 담긴 센터마다 출고방식(택배/파렛트) 선택. 확정 시 센터마다 출고 묶음 1건(`status=출고준비`, 센터입고일=출고일). 작업 중 상태는 `cupang_box_calc_drafts` 에 이름 붙여 임시 저장/불러오기
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia`
- 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1박스=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1박스 묶음 기준 Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 박스 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md`
+32 -67
View File
@@ -129,28 +129,43 @@ async def index(request: Request) -> HTMLResponse:
store, user = guard
year, month = _ym(request)
counts = store.calendar_counts(year=year, month=month)
shipments = store.list_shipments(year=year, month=month)
# 달력 칸에는 출고 건수와 센터 수만 보여준다.
counts: dict[str, dict[str, Any]] = {}
for s in shipments:
d = s.get("ship_date")
if not d:
continue
cell = counts.setdefault(str(d), {"ship": 0, "centers": set()})
cell["ship"] += 1
cell["centers"].add(s.get("center_id") or s.get("center_name_snapshot") or "")
for cell in counts.values():
cell["centers"] = len(cell["centers"])
# 선택 날짜 (기본: 오늘이 이번 달이면 오늘, 아니면 1일)
sel = request.query_params.get("date") or ""
today = today_kst()
if not sel:
sel = today.isoformat() if (today.year == year and today.month == month) else f"{year:04d}-{month:02d}-01"
# 선택일에 걸친 묶음(출고일 기준 우선, 작성/입고 포함)
sel_shipments = [
s for s in shipments
if sel in (s.get("ship_date"), s.get("document_date"), s.get("center_arrival_date"))
]
# 각 묶음에 품목 요약(제품명/수량) 첨부 — hover 툴팁용
# 선택일의 묶음 — 달력과 같은 기준(출고일)
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"])
s["tip_items"] = [
{"name": ln.get("product_name_snapshot") or ln.get("product_code"),
"qty": ln.get("quantity", 0)}
for ln in (full.get("lines") if full else [])
lines = (full.get("lines") if full else []) or []
s["items"] = [
{
"name": ln.get("product_name_snapshot") or ln.get("product_code"),
"qty": int(ln.get("quantity") or 0),
"boxes": int(ln.get("calculated_boxes") or 0),
}
for ln in lines
]
s["total_qty"] = sum(it["qty"] for it in s["items"])
s["total_boxes"] = sum(it["boxes"] for it in s["items"])
cal = _calendar.Calendar(firstweekday=6) # 일요일 시작
weeks = cal.monthdatescalendar(year, month)
@@ -216,63 +231,13 @@ def _form_context(request: Request, store: Any, user: dict[str, Any]) -> dict[st
}
@router.get("/new", response_class=HTMLResponse)
async def new_form(request: Request) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
@router.get("/new")
async def new_form(request: Request) -> RedirectResponse:
"""신규 등록 폼은 없앴다. 출고 묶음은 박스 계산의 [분배 확정] 으로만 만든다.
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
store, user = guard
ctx = _form_context(request, store, user)
ctx.update(
{
"page_title": "쿠팡 밀크런 — 신규 등록",
"page_subtitle": "공통 헤더 1개 + 품목 라인",
"mode": "new",
"shipment": None,
"default_date": today_kst().isoformat(),
}
)
return render_template(request, "cupang/form.html", ctx)
@router.post("/new")
async def create(
request: Request,
lines_json: str = Form("[]"),
document_date: str = Form(...),
ship_date: str = Form(...),
center_arrival_date: str = Form(...),
center_id: str = Form(""),
center_name_snapshot: str = Form(""),
ship_method: str = Form("택배"),
outbound_summary: str = Form(""),
worker: str = Form(""),
memo: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
header = {
"document_date": document_date,
"ship_date": ship_date,
"center_arrival_date": center_arrival_date,
"center_id": center_id,
"center_name_snapshot": center_name_snapshot,
"ship_method": ship_method,
"outbound_summary": outbound_summary,
"worker": worker,
"memo": memo,
}
try:
ship = store.create_shipment(
created_by=user["email"], header=header, lines=_parse_lines(lines_json)
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/cupang/{ship['id']}", status_code=303)
예전 링크/북마크가 404 나지 않도록 박스 계산으로 보낸다.
"""
return RedirectResponse(url="/cupang/box-calc", status_code=303)
@router.get("/{shipment_id:int}", response_class=HTMLResponse)
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831h" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831i" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -163,7 +163,7 @@
<label class="erp-field"><span>제목</span>
<input class="erp-input" type="text" id="cpg-draft-name" maxlength="100"
placeholder="예: 8/31 인천분" autocomplete="off" /></label>
placeholder="예: 2026.08.31(월) 오후 04시 50분" 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>
@@ -1054,10 +1054,15 @@
return skipped;
}
var DOW_KO = ["일", "월", "화", "수", "목", "금", "토"];
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());
var h = d.getHours();
var ampm = h < 12 ? "오전" : "오후";
var h12 = h % 12 || 12;
return d.getFullYear() + "." + pad2(d.getMonth() + 1) + "." + pad2(d.getDate()) +
"(" + DOW_KO[d.getDay()] + ") " + ampm + " " + pad2(h12) + "시 " + pad2(d.getMinutes()) + "분";
}
function closeDraft() { draftDlg.hidden = true; }
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831h" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831i" />{% 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=20260831h" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831i" />{% 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=20260831h" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831i" />{% 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=20260831h" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831i" />{% endblock %}
{% block content %}
<section class="cpg">
+21 -61
View File
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831h" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831i" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -8,12 +8,11 @@
<!-- 페이지 액션 (달력 컬럼 폭에 맞춤: 신규 좌측 / 설정 달력 오른쪽 끝) -->
<div class="cpg-actions-grid">
<div class="cpg-actions-main">
<a class="erp-btn erp-btn-primary" href="/cupang/new">+ 신규 등록</a>
<a class="erp-btn erp-btn-primary" href="/cupang/box-calc">+ 신규 등록</a>
<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-calc">박스 계산</a>
</span>
</div>
<div class="cpg-actions-spacer"></div>
@@ -45,9 +44,10 @@
href="/cupang/?year={{ year }}&month={{ month }}&date={{ cell.date }}">
<span class="cpg-cal-day">{{ cell.day }}</span>
<span class="cpg-cal-badges">
{% if cell.counts.document %}<span class="erp-badge erp-badge-neutral cpg-mini">작성 {{ cell.counts.document }}</span>{% endif %}
{% if cell.counts.ship %}<span class="erp-badge erp-badge-inverse cpg-mini">출고 {{ cell.counts.ship }}</span>{% endif %}
{% if cell.counts.arrival %}<span class="erp-badge erp-badge-success cpg-mini">입고 {{ cell.counts.arrival }}</span>{% endif %}
{% if cell.counts.ship %}
<span class="erp-badge erp-badge-inverse cpg-mini">출고 {{ cell.counts.ship }}</span>
<span class="erp-badge erp-badge-neutral cpg-mini">센터 {{ cell.counts.centers }}</span>
{% endif %}
</span>
</a>
{% endfor %}
@@ -66,75 +66,35 @@
<ul class="cpg-list">
{% for s in sel_shipments %}
<li class="cpg-list-item">
<a href="/cupang/{{ s.id }}" class="cpg-list-link cpg-hover-item"
data-items='{{ s.tip_items | tojson }}'>
<div class="cpg-list-top">
<a href="/cupang/{{ s.id }}" class="cpg-ship-card">
<div class="cpg-ship-head">
<strong>{{ s.center_name_snapshot or '센터 미지정' }}</strong>
<span class="erp-badge erp-badge-neutral cpg-mini">{{ s.ship_method }}</span>
</div>
<div class="cpg-list-meta erp-muted">
출고 {{ s.ship_date }} · 입고 {{ s.center_arrival_date }} · {{ s.ship_method }}
{% if s.outbound_summary %}<br>{{ s.outbound_summary }}{% endif %}
<div class="cpg-ship-meta erp-muted">
출고 {{ s.ship_date }} · {{ s.total_boxes }}박스 · {{ s.total_qty }}
</div>
<ul class="cpg-ship-items">
{% for it in s['items'] %}
<li>
<span class="cpg-ship-iname">{{ it.name }}</span>
<span class="cpg-ship-iqty">{{ it.qty }}개</span>
<span class="cpg-ship-ibox">{{ it.boxes }}박스</span>
</li>
{% endfor %}
</ul>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="erp-muted cpg-empty">선택한 날짜의 출고 묶음이 없습니다.
<a href="/cupang/new">신규 등록</a></p>
<a href="/cupang/box-calc">박스 계산에서 등록</a></p>
{% endif %}
</div>
</div>
<!-- hover 툴팁 (마우스 따라다님, 커서 왼쪽) -->
<div id="cpg-hover-tip" class="cpg-hover-tip" hidden></div>
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
var tip = document.getElementById("cpg-hover-tip");
if (!tip) return;
function buildHtml(items) {
if (!items || !items.length) return '<div class="cpg-tip-empty">품목 없음</div>';
var html = "";
items.forEach(function (it) {
var d1 = document.createElement("div");
d1.className = "cpg-tip-row";
var n = document.createElement("span"); n.className = "cpg-tip-name"; n.textContent = it.name || "";
var q = document.createElement("span"); q.className = "cpg-tip-qty"; q.textContent = (it.qty != null ? it.qty : 0) + "개";
d1.appendChild(n); d1.appendChild(q);
html += d1.outerHTML;
});
return html;
}
function move(e) {
// 커서 왼쪽에 표시
var w = tip.offsetWidth || 200;
var x = e.clientX - w - 14;
if (x < 6) x = e.clientX + 16; // 화면 왼쪽 벗어나면 오른쪽으로
var y = e.clientY + 12;
var maxY = window.innerHeight - tip.offsetHeight - 8;
if (y > maxY) y = maxY;
tip.style.left = x + "px";
tip.style.top = y + "px";
}
document.querySelectorAll(".cpg-hover-item").forEach(function (el) {
el.addEventListener("mouseenter", function (e) {
var items = [];
try { items = JSON.parse(el.getAttribute("data-items") || "[]"); } catch (_) {}
tip.innerHTML = buildHtml(items);
tip.hidden = false;
move(e);
});
el.addEventListener("mousemove", move);
el.addEventListener("mouseleave", function () { tip.hidden = true; });
});
})();
</script>
{% endblock %}
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831h" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831i" />{% endblock %}
{% block content %}
<section class="cpg">
+27
View File
@@ -809,6 +809,33 @@ 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 .cpg-dist-center { border-color: var(--color-midtone-gray); }
/* 달력 오른쪽 — 센터별 출고 카드 */
.cpg-ship-card {
display: block;
padding: 10px 12px;
border: 1px solid var(--color-subtle-ash);
border-radius: 10px;
text-decoration: none;
color: inherit;
}
.cpg-ship-card:hover { border-color: var(--color-rich-black); background: var(--color-ghost-gray); }
.cpg-ship-head { display: flex; align-items: center; gap: 6px; }
.cpg-ship-head > strong { flex: 1 1 auto; min-width: 0; font-size: 14px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cpg-ship-meta { margin-top: 2px; font-size: 12px; font-family: var(--font-geist-mono); }
.cpg-ship-items {
list-style: none; margin: 8px 0 0; padding: 6px 0 0;
border-top: 1px solid var(--color-ghost-gray);
}
.cpg-ship-items > li { display: flex; align-items: baseline; gap: 6px; padding: 2px 0; font-size: 13px; }
.cpg-ship-iname { flex: 1 1 auto; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cpg-ship-iqty, .cpg-ship-ibox {
flex: 0 0 auto; font-family: var(--font-geist-mono); font-size: 12px;
}
.cpg-ship-iqty { color: var(--color-midtone-gray); }
.cpg-ship-ibox { font-weight: 600; }
/* ③ 헤더의 분배 확정 버튼 */
.cpg-dist-head-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cpg-dist-head-bar > .erp-muted { flex: 1 1 auto; min-width: 0; }