feat(cupang): 출고 등록 경로를 박스 계산으로 일원화, 달력 표시 정리
출고 묶음을 만드는 길이 둘(신규 등록 폼 / 박스 계산 분배 확정)이라 헷갈렸다. 폼 경로를 없애고 [+ 신규 등록] 이 박스 계산을 열게 한다. 중복이던 오른쪽 [박스 계산] 버튼은 제거. /cupang/new 는 북마크가 깨지지 않게 박스 계산으로 리다이렉트하고 POST /new(생성)는 삭제했다. 기존 묶음 수정은 그대로 동작한다. 달력 칸은 작성/출고/입고 세 배지를 늘어놓아 읽기 어려웠다. 출고 기준으로 "출고 N건 · 센터 M곳"만 남긴다. 오른쪽 상세도 같은 기준(출고일)으로 맞추고 센터별 출고방식·총 박스/개수와 상품별 수량·박스를 카드 안에 펼친다. 품목이 바로 보이므로 hover 툴팁은 제거. 임시 저장 기본 제목은 "2026.08.31(월) 오후 04시 50분" 형식으로.
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user