refactor(cupang): 입고센터 관리 화면 제거

- /cupang/centers GET/POST, 수정·삭제 라우트와 centers.html 삭제
- 달력 상단 "입고센터 관리" 버튼 제거, 상자 계산의 안내 문구 정리
- 센터 관리 화면 전용 CSS 제거(공용 .cpg-icon-btn/.cpg-btn-sm 은 유지)
- 센터 데이터(cupang_centers)와 db 계층은 그대로 — 출고 확정·발주 업로드에서 계속 사용

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 20:34:41 +09:00
parent 46ce6d78b8
commit 1a2291f510
21 changed files with 2268 additions and 447 deletions
+1 -1
View File
@@ -312,7 +312,7 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
"key": "cupang",
"title": "쿠팡 밀크런",
"subtitle": "Coupang Milk-run",
"description": "쿠팡 밀크런 출고 일정·박스 계산·입고센터를 달력에서 관리합니다.",
"description": "쿠팡 밀크런 출고 일정·상자 계산 달력에서 관리합니다.",
"url": "/cupang/",
"health_url": "/cupang/health",
"status": "ready",
-146
View File
@@ -11,10 +11,8 @@ from __future__ import annotations
import calendar as _calendar
import json
import re
from fractions import Fraction
from typing import Any
from urllib.parse import quote
from datetime import date as _date, timedelta as _timedelta
@@ -558,150 +556,6 @@ async def day_delete(
)
# ════════════════════════════════════════════════════════════
# 입고센터 관리
# ════════════════════════════════════════════════════════════
_CENTER_PREFIX_RE = re.compile(r"^\D*")
def _center_group(name: str) -> str:
"""센터명 앞부분(숫자 전까지)을 지역 그룹 키로 쓴다. 예) 인천14 → 인천."""
name = (name or "").strip()
prefix = _CENTER_PREFIX_RE.match(name).group(0).strip()
return prefix or name or "기타"
def _center_sort_key(name: str) -> list[Any]:
"""숫자를 숫자로 비교하는 자연 정렬. 예) 인천4 < 인천14."""
parts = re.split(r"(\d+)", (name or "").strip())
return [(1, int(p), "") if p.isdigit() else (0, 0, p.lower()) for p in parts]
def _group_centers(centers: list[dict[str, Any]]) -> list[dict[str, Any]]:
buckets: dict[str, list[dict[str, Any]]] = {}
for c in centers:
buckets.setdefault(_center_group(c["name"]), []).append(c)
groups = []
for key, items in buckets.items():
items.sort(key=lambda c: _center_sort_key(c["name"]))
groups.append({"name": key, "items": items, "count": len(items)})
groups.sort(key=lambda g: _center_sort_key(g["name"]))
return groups
@router.get("/centers", response_class=HTMLResponse)
async def centers_page(request: Request) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
store, user = guard
centers = sorted(
store.list_centers(include_inactive=True),
key=lambda c: _center_sort_key(c["name"]),
)
return render_template(
request,
"cupang/centers.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런 — 입고센터 관리",
"page_subtitle": "센터명 등록 · 수정 · 활성/비활성 · 삭제.",
"centers": centers,
"center_groups": _group_centers(centers),
"flash": request.query_params.get("msg", ""),
"flash_name": request.query_params.get("name", ""),
},
)
@router.post("/centers")
async def center_create(
request: Request,
name: str = Form(...),
sort_order: int = Form(0),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
name = (name or "").strip()
if not name:
return RedirectResponse(url="/cupang/centers", status_code=303)
# 같은 이름이 이미 있으면 새로 만들지 않고 알림만 돌려준다.
existing = next(
(
c
for c in store.list_centers(include_inactive=True)
if (c.get("name") or "").strip().lower() == name.lower()
),
None,
)
if existing is not None:
msg = "dup" if existing.get("active") else "dup_inactive"
return RedirectResponse(
url=f"/cupang/centers?msg={msg}&name={quote(name)}", status_code=303
)
try:
store.create_center(name=name, sort_order=sort_order)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(
url=f"/cupang/centers?msg=added&name={quote(name)}", status_code=303
)
@router.post("/centers/{center_id}/edit")
async def center_edit(
request: Request,
center_id: int,
active: str = Form(""),
sort_order: 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 미설정")
# 이름 변경은 지원하지 않는다(기존 출고 이력과 어긋날 수 있음). 활성/정렬만 수정.
kwargs: dict[str, Any] = {"center_id": center_id}
if active != "":
kwargs["active"] = active in ("1", "true", "on", "True")
if sort_order.strip():
try:
kwargs["sort_order"] = int(sort_order)
except ValueError:
pass
try:
store.update_center(**kwargs)
except KeyError:
raise HTTPException(status_code=404, detail="센터를 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url="/cupang/centers", status_code=303)
@router.post("/centers/{center_id}/delete")
async def center_delete(
request: Request,
center_id: int,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.delete_center(center_id=center_id)
except KeyError:
raise HTTPException(status_code=404, detail="센터를 찾을 수 없습니다.")
return RedirectResponse(url="/cupang/centers", status_code=303)
# ════════════════════════════════════════════════════════════
# 상자 입수량 관리
# ════════════════════════════════════════════════════════════
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903e" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903f" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -108,7 +108,7 @@
{% if not centers %}
<p class="erp-muted">
등록된 입고센터가 없습니다. <a href="/cupang/centers">입고센터 관리</a>에서 먼저 센터를 추가하세요.
등록된 입고센터가 없습니다. 관리자에게 문의하세요.
</p>
{% else %}
<div class="cpg-dist-list" id="cpg-dist-list"></div>
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903e" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903f" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1,187 +0,0 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903e" />{% endblock %}
{% block content %}
<section class="cpg">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
</div>
<div class="erp-card cpg-form-card cpg-center-card">
<!-- 상단: 추가 + 검색 -->
<div class="cpg-center-bar">
<form method="post" action="/cupang/centers" class="cpg-center-addform">
<input class="erp-input cpg-center-addinput" type="text" name="name"
placeholder="센터명 입력 (예: 대구3)" required autocomplete="off" />
<button type="submit" class="erp-btn erp-btn-primary">추가</button>
</form>
<input class="erp-input cpg-center-search" type="search" id="cpgCenterSearch"
placeholder="센터 검색" autocomplete="off" />
</div>
<div class="cpg-card-head cpg-center-head">
<h2>센터 목록 <span class="erp-muted" id="cpgCenterCount">({{ centers|length }})</span></h2>
<span class="erp-muted">지역별로 묶어 표시. 이름 변경은 지원하지 않음 — 삭제 후 새로 등록.</span>
</div>
<!-- 목록: 지역 그룹 패널, 위→아래로 읽고 열로 흐름 -->
<div class="cpg-center-cols" id="cpgCenterGrid">
{% for g in center_groups %}
<section class="cpg-center-group" data-group="{{ g.name|lower }}">
<button type="button" class="cpg-center-gtitle" aria-expanded="true">
<span class="cpg-center-caret" aria-hidden="true"></span>
<span class="cpg-center-gname">{{ g.name }}</span>
<span class="cpg-center-gcount">{{ g.count }}</span>
</button>
<div class="cpg-center-glist">
{% for c in g["items"] %}
<div class="cpg-center-row {% if not c.active %}is-inactive{% endif %}" data-name="{{ c.name|lower }}">
<span class="cpg-center-dot" title="{% if c.active %}활성{% else %}비활성{% endif %}"></span>
<span class="cpg-center-name">{{ c.name }}</span>
<span class="cpg-center-acts">
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-inline-form">
<input type="hidden" name="active" value="{% if c.active %}0{% else %}1{% endif %}" />
<button type="submit" class="cpg-icon-btn"
title="{% if c.active %}비활성으로 전환{% else %}활성으로 전환{% endif %}">{% if c.active %}비활성{% else %}활성{% endif %}</button>
</form>
<button type="button" class="cpg-icon-btn is-danger" title="삭제"
data-del-url="/cupang/centers/{{ c.id }}/delete" data-del-name="{{ c.name }}">삭제</button>
</span>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
</div>
<p class="cpg-center-empty erp-muted" id="cpgCenterEmpty" hidden>검색 결과 없음</p>
</div>
</section>
<!-- 화면 가운데 알림/확인 팝업 -->
<div class="cpg-modal" id="cpgCenterDlg" hidden>
<div class="cpg-modal-back" data-dlg-close></div>
<div class="cpg-modal-box cpg-center-dlg" role="dialog" aria-modal="true" aria-labelledby="cpgCenterDlgTitle">
<h3 id="cpgCenterDlgTitle">알림</h3>
<p class="cpg-center-dlgmsg" id="cpgCenterDlgMsg"></p>
<div class="cpg-dlg-actions cpg-center-dlgact">
<button type="button" class="erp-btn erp-btn-outline" id="cpgCenterDlgCancel" data-dlg-close hidden>취소</button>
<button type="button" class="erp-btn erp-btn-primary" id="cpgCenterDlgOk">확인</button>
</div>
</div>
</div>
<form method="post" id="cpgCenterDelForm" hidden></form>
<script>
(function () {
// ── 가운데 팝업 (alert/confirm 대체) ──
var dlg = document.getElementById('cpgCenterDlg');
var dlgTitle = document.getElementById('cpgCenterDlgTitle');
var dlgMsg = document.getElementById('cpgCenterDlgMsg');
var dlgOk = document.getElementById('cpgCenterDlgOk');
var dlgCancel = document.getElementById('cpgCenterDlgCancel');
var onConfirm = null;
var lastFocus = null;
function closeDlg() {
dlg.hidden = true;
onConfirm = null;
if (lastFocus && lastFocus.focus) lastFocus.focus();
}
function openDlg(opts) {
lastFocus = document.activeElement;
dlgTitle.textContent = opts.title || '알림';
dlgMsg.textContent = opts.message || '';
onConfirm = opts.onConfirm || null;
dlgCancel.hidden = !opts.onConfirm;
dlgOk.textContent = opts.okText || '확인';
dlgOk.classList.toggle('erp-btn-danger', !!opts.danger);
dlgOk.classList.toggle('erp-btn-primary', !opts.danger);
dlg.hidden = false;
dlgOk.focus();
}
dlgOk.addEventListener('click', function () {
var fn = onConfirm;
closeDlg();
if (fn) fn();
});
Array.prototype.forEach.call(dlg.querySelectorAll('[data-dlg-close]'), function (el) {
el.addEventListener('click', closeDlg);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !dlg.hidden) closeDlg();
});
// ── 추가 결과 알림 (중복 등록 차단) ──
var flash = {{ flash|tojson }};
var fname = {{ flash_name|tojson }};
if (flash === 'dup') {
openDlg({ title: '이미 등록된 센터', message: '"' + fname + '" 센터는 이미 목록에 있습니다.' });
} else if (flash === 'dup_inactive') {
openDlg({
title: '이미 등록된 센터',
message: '"' + fname + '" 센터는 이미 목록에 있습니다. 지금은 비활성 상태이니 목록에서 [활성] 을 눌러 사용하세요.'
});
}
if (flash) history.replaceState(null, '', '/cupang/centers');
// ── 삭제 확인 ──
var delForm = document.getElementById('cpgCenterDelForm');
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-del-url]');
if (!btn) return;
openDlg({
title: '센터 삭제',
message: '"' + btn.dataset.delName + '" 센터를 삭제할까요?',
okText: '삭제',
danger: true,
onConfirm: function () {
delForm.action = btn.dataset.delUrl;
delForm.submit();
}
});
});
// ── 그룹 접기 ──
document.addEventListener('click', function (e) {
var head = e.target.closest('.cpg-center-gtitle');
if (!head) return;
var group = head.closest('.cpg-center-group');
var collapsed = group.classList.toggle('is-collapsed');
head.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
});
// ── 검색 ──
var box = document.getElementById('cpgCenterSearch');
var grid = document.getElementById('cpgCenterGrid');
var count = document.getElementById('cpgCenterCount');
var empty = document.getElementById('cpgCenterEmpty');
if (!box || !grid) return;
var groups = Array.prototype.slice.call(grid.querySelectorAll('.cpg-center-group'));
box.addEventListener('input', function () {
var q = box.value.trim().toLowerCase();
var n = 0;
groups.forEach(function (g) {
var shown = 0;
Array.prototype.forEach.call(g.querySelectorAll('.cpg-center-row'), function (r) {
var hit = !q || (r.dataset.name || '').indexOf(q) !== -1;
r.style.display = hit ? '' : 'none';
if (hit) shown += 1;
});
g.style.display = shown ? '' : 'none';
if (q && shown) g.classList.remove('is-collapsed');
n += shown;
});
if (count) count.textContent = '(' + n + ')';
if (empty) empty.hidden = n !== 0;
});
})();
</script>
{% endblock %}
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903e" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903f" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -11,7 +11,6 @@
<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>
</span>
</div>
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903e" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903f" />{% 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=20260903e" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903f" />{% endblock %}
{% block content %}
{# 출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}
-104
View File
@@ -169,104 +169,6 @@
.cpg-inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; }
.cpg-row-actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* ── 입고센터 관리 (상단: 추가·검색 / 아래: 지역 그룹 패널) ── */
.cpg-center-card { padding: 16px; }
.cpg-center-bar {
display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
padding-bottom: 12px; border-bottom: 1px solid var(--color-subtle-ash);
}
.cpg-center-addform { display: flex; gap: 6px; align-items: center; margin: 0; flex: 1 1 320px; min-width: 0; }
.cpg-center-addinput { flex: 1 1 auto; min-width: 0; }
.cpg-center-search { flex: 0 1 240px; min-width: 0; }
.cpg-center-head { margin: 12px 0 10px; }
/* 멀티컬럼: 한 열을 위→아래로 읽고, 다 차면 다음 열로 */
.cpg-center-cols {
columns: 4 230px;
column-gap: 16px;
}
/* 그룹 = 헤더 바 + 목록 패널 */
.cpg-center-group {
break-inside: avoid; page-break-inside: avoid;
margin: 0 0 12px;
border: 1px solid var(--color-subtle-ash);
border-radius: 8px;
overflow: hidden;
background: var(--color-canvas-white);
}
.cpg-center-gtitle {
display: flex; align-items: center; gap: 6px; width: 100%;
padding: 7px 10px;
border: 0; border-bottom: 1px solid var(--color-subtle-ash);
background: var(--color-ghost-gray);
cursor: pointer; text-align: left;
font-family: inherit;
}
.cpg-center-gtitle:hover { background: var(--color-subtle-ash); }
.cpg-center-gname {
flex: 1 1 auto; min-width: 0;
font-size: 12px; font-weight: 700; letter-spacing: .04em;
color: var(--color-rich-black);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cpg-center-gcount {
flex: 0 0 auto;
font-family: var(--font-geist-mono);
font-size: 11px; font-weight: 600; line-height: 1;
padding: 3px 6px; border-radius: 4px;
background: var(--color-canvas-white);
border: 1px solid var(--color-subtle-ash);
color: var(--color-midtone-gray);
}
/* 접기 캐럿 */
.cpg-center-caret {
flex: 0 0 auto; width: 0; height: 0;
border-left: 4px solid var(--color-midtone-gray);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transform: rotate(90deg);
transition: transform .12s ease;
}
.cpg-center-group.is-collapsed .cpg-center-caret { transform: rotate(0deg); }
.cpg-center-group.is-collapsed .cpg-center-glist { display: none; }
.cpg-center-group.is-collapsed .cpg-center-gtitle { border-bottom: 0; }
.cpg-center-glist { padding: 2px 0; }
.cpg-center-row {
display: flex; align-items: center; gap: 8px;
padding: 5px 10px;
min-width: 0;
}
.cpg-center-row + .cpg-center-row { border-top: 1px solid var(--color-ghost-gray); }
.cpg-center-row:hover { background: var(--color-ghost-gray); }
.cpg-center-dot {
flex: 0 0 auto; width: 6px; height: 6px; border-radius: 50%;
background: var(--color-success-green);
}
.cpg-center-row.is-inactive .cpg-center-name { color: var(--color-midtone-gray); text-decoration: line-through; }
.cpg-center-row.is-inactive .cpg-center-dot { background: var(--color-subtle-ash); }
.cpg-center-name {
flex: 1 1 auto; min-width: 0;
font-family: var(--font-geist-mono);
font-size: 13px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* 행 액션: hover/focus 시에만 노출 */
.cpg-center-acts {
display: inline-flex; gap: 2px; flex: 0 0 auto;
opacity: 0; transition: opacity .12s ease;
}
.cpg-center-row:hover .cpg-center-acts,
.cpg-center-row:focus-within .cpg-center-acts { opacity: 1; }
@media (hover: none) {
.cpg-center-acts { opacity: .7; }
}
.cpg-icon-btn {
border: 0; background: none; cursor: pointer;
font-family: inherit;
@@ -276,12 +178,6 @@
.cpg-icon-btn:hover { background: var(--color-subtle-ash); color: var(--color-rich-black); }
.cpg-icon-btn.is-danger:hover { background: var(--color-subtle-ash); color: var(--color-callout-red); }
.cpg-center-empty { margin: 8px 0 0; }
/* 센터 관리 팝업 */
.cpg-center-dlgmsg { margin: 0; font-size: 14px; line-height: 1.5; word-break: keep-all; }
.cpg-center-dlgact { display: flex; gap: 6px; justify-content: flex-end; margin-top: 4px; }
.cpg-btn-sm { padding: 3px 8px; font-size: 12px; flex: 0 0 auto; }
/* ── 라인 테이블 ── */
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB