feat(cupang): 제품명 카탈로그 설정 + 달력 공휴일 색상 + 폼 UI 개선
- 설정 화면 추가: /cupang/products — itemcode_db 검색 등록 + 수동 등록. 제품명 드롭다운 소스. cupang_products 테이블 신규(init.sql 멱등 재실행 필요) - 폼 품목라인: 제품명 드롭다운 선택 → 제품코드 자동 입력 - 라인 컬럼 순서 제품명·제품코드·수량·입수량·박스계산·메모 - 제품코드 140px, 수량·입수량 60px 폭 고정 - 공통 헤더 입력란 겹침 수정(min-width:0, width:100%, box-sizing) - 달력: 일/공휴일 빨강, 토 파랑, 글자 크기 확대. 한국 공휴일 모듈(holidays.py) - document_no null → "None" 출력 버그 수정 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -201,6 +201,59 @@ class CupangDBStore:
|
||||
"""product_code → rule. 폼/계산에서 빠르게 조회하기 위한 dict."""
|
||||
return {r["product_code"]: r for r in self.list_box_rules()}
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 제품명 카탈로그 (cupang_products)
|
||||
# itemcode_db 에서 가져와 등록한 제품 목록. 폼의 제품명 드롭다운 소스.
|
||||
# 제품명 선택 → product_code 자동 채움.
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_products(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
|
||||
where = "" if include_inactive else "WHERE active = TRUE"
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM cupang_products {where} "
|
||||
"ORDER BY active DESC, sort_order ASC, product_name ASC"
|
||||
).fetchall()
|
||||
return [self._product_serialize(r) for r in rows]
|
||||
|
||||
def upsert_product(
|
||||
self, *, product_code: str, product_name: str, sort_order: int = 0
|
||||
) -> dict[str, Any]:
|
||||
code = (product_code or "").strip()
|
||||
name = (product_name or "").strip()
|
||||
if not code or not name:
|
||||
raise ValueError("제품코드와 제품명 모두 필요합니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO cupang_products (product_code, product_name, sort_order)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (product_code) DO UPDATE
|
||||
SET product_name = EXCLUDED.product_name,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
active = TRUE
|
||||
RETURNING *
|
||||
""",
|
||||
(code, name, sort_order),
|
||||
).fetchone()
|
||||
return self._product_serialize(row)
|
||||
|
||||
def deactivate_product(self, *, product_id: int) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE cupang_products SET active = FALSE WHERE id = %s",
|
||||
(product_id,),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(product_id)
|
||||
|
||||
def delete_product(self, *, product_id: int) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM cupang_products WHERE id = %s", (product_id,)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(product_id)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 출고 묶음 (cupang_shipments + cupang_shipment_lines)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@@ -549,6 +602,20 @@ class CupangDBStore:
|
||||
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _product_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
out["active"] = bool(out.get("active", True))
|
||||
out["sort_order"] = int(out.get("sort_order", 0))
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _shipment_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""대한민국 공휴일 판정 (달력 색상용).
|
||||
|
||||
- 고정 양력 공휴일은 매년 동일 → 연도 무관 판정.
|
||||
- 음력 공휴일(설날/부처님오신날/추석)과 대체공휴일은 매년 달라짐 →
|
||||
연도별 dict(`_LUNAR_AND_SUBSTITUTE`)에 명시. 새 연도는 KASI 발표값을 추가한다.
|
||||
|
||||
미수록 연도는 고정 양력 공휴일만 빨강 처리된다(음력/대체는 누락).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
# 매년 동일한 양력 공휴일 (month, day)
|
||||
_FIXED_SOLAR: set[tuple[int, int]] = {
|
||||
(1, 1), # 신정
|
||||
(3, 1), # 삼일절
|
||||
(5, 5), # 어린이날
|
||||
(6, 6), # 현충일
|
||||
(8, 15), # 광복절
|
||||
(10, 3), # 개천절
|
||||
(10, 9), # 한글날
|
||||
(12, 25), # 성탄절
|
||||
}
|
||||
|
||||
# 연도별 음력 공휴일 + 대체공휴일 (ISO 날짜 문자열). KASI 발표 기준.
|
||||
_LUNAR_AND_SUBSTITUTE: dict[int, set[str]] = {
|
||||
2025: {
|
||||
"2025-01-28", "2025-01-29", "2025-01-30", # 설날 연휴
|
||||
"2025-03-03", # 삼일절 대체(3/1 토)
|
||||
"2025-05-06", # 부처님오신날 대체(5/5 겹침)
|
||||
"2025-05-05", # 부처님오신날(어린이날과 동일일)
|
||||
"2025-10-06", "2025-10-07", "2025-10-08", # 추석 연휴
|
||||
"2025-10-08", # 추석 대체 가능
|
||||
},
|
||||
2026: {
|
||||
"2026-02-16", "2026-02-17", "2026-02-18", # 설날 연휴 (설날 2/17)
|
||||
"2026-03-02", # 삼일절 대체 (3/1 일)
|
||||
"2026-05-24", # 부처님오신날 (일)
|
||||
"2026-05-25", # 부처님오신날 대체
|
||||
"2026-08-17", # 광복절 대체 (8/15 토)
|
||||
"2026-09-24", "2026-09-25", "2026-09-26", # 추석 연휴 (추석 9/25)
|
||||
"2026-09-28", # 추석 대체 (9/26 토)
|
||||
"2026-10-05", # 개천절 대체 (10/3 토)
|
||||
},
|
||||
2027: {
|
||||
"2027-02-06", "2027-02-07", "2027-02-08", # 설날 연휴 (설날 2/7)
|
||||
"2027-02-09", # 설날 대체 (2/7 일)
|
||||
"2027-05-13", # 부처님오신날 (목)
|
||||
"2027-08-16", # 광복절 대체 (8/15 일)
|
||||
"2027-09-14", "2027-09-15", "2027-09-16", # 추석 연휴 (추석 9/15)
|
||||
"2027-10-04", # 개천절 대체 (10/3 일)
|
||||
"2027-10-11", # 한글날 대체 (10/9 토)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def is_holiday(d: date) -> bool:
|
||||
"""공휴일(일요일 제외)이면 True. 일/토 색상은 요일로 따로 판정한다."""
|
||||
if (d.month, d.day) in _FIXED_SOLAR:
|
||||
return True
|
||||
return d.isoformat() in _LUNAR_AND_SUBSTITUTE.get(d.year, set())
|
||||
@@ -23,6 +23,7 @@ from fastapi.responses import (
|
||||
StreamingResponse,
|
||||
)
|
||||
|
||||
from .holidays import is_holiday
|
||||
from .store import DEFAULT_CENTERS, SHEET_COLUMNS, SHIP_METHODS, STATUSES, compute_boxes
|
||||
|
||||
router = APIRouter(prefix="/cupang", tags=["cupang"])
|
||||
@@ -153,6 +154,9 @@ async def index(request: Request) -> HTMLResponse:
|
||||
"in_month": d.month == month,
|
||||
"is_today": d == today,
|
||||
"is_selected": d.isoformat() == sel,
|
||||
"is_sunday": d.weekday() == 6,
|
||||
"is_saturday": d.weekday() == 5,
|
||||
"is_holiday": is_holiday(d),
|
||||
"counts": counts.get(d.isoformat(), {}),
|
||||
}
|
||||
for d in week
|
||||
@@ -212,6 +216,7 @@ def _form_context(request: Request, store: Any, user: dict[str, Any]) -> dict[st
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"centers": store.list_centers(),
|
||||
"box_rules": store.list_box_rules(),
|
||||
"products": store.list_products(),
|
||||
"ship_methods": list(SHIP_METHODS),
|
||||
"statuses": list(STATUSES),
|
||||
"search_enabled": bool(reader and reader.enabled),
|
||||
@@ -583,6 +588,71 @@ async def box_rule_delete(
|
||||
return RedirectResponse(url="/cupang/box-rules", status_code=303)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 설정 — 제품명 카탈로그 관리 (itemcode_db 에서 등록)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@router.get("/products", response_class=HTMLResponse)
|
||||
async def products_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
|
||||
reader = _itemcode(request)
|
||||
return render_template(
|
||||
request,
|
||||
"cupang/products.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"page_title": "쿠팡 밀크런 — 설정 (제품명)",
|
||||
"page_subtitle": "제품명 리스트 관리. itemcode_db 에서 검색해 등록하면 폼 드롭다운에 노출됩니다.",
|
||||
"products": store.list_products(include_inactive=True),
|
||||
"search_enabled": bool(reader and reader.enabled),
|
||||
"search_reason": (reader.reason if reader else ""),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/products")
|
||||
async def product_upsert(
|
||||
request: Request,
|
||||
product_code: str = Form(...),
|
||||
product_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 미설정")
|
||||
try:
|
||||
store.upsert_product(
|
||||
product_code=product_code, product_name=product_name, sort_order=sort_order
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url="/cupang/products", status_code=303)
|
||||
|
||||
|
||||
@router.post("/products/{product_id:int}/delete")
|
||||
async def product_delete(
|
||||
request: Request,
|
||||
product_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.deactivate_product(product_id=product_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.")
|
||||
return RedirectResponse(url="/cupang/products", status_code=303)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 상품 검색 (itemcode_db 읽기 전용) + 박스 계산 미리보기
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
<label class="erp-field"><span>문서번호(선택)</span>
|
||||
<input class="erp-input" type="text" name="document_no"
|
||||
value="{{ shipment.document_no if shipment else '' }}" /></label>
|
||||
value="{{ shipment.document_no or '' if shipment else '' }}" /></label>
|
||||
</div>
|
||||
|
||||
<label class="erp-field cpg-full"><span>출고/박스 요약 (수동 보정 메모)</span>
|
||||
@@ -71,8 +71,8 @@
|
||||
<div class="cpg-card-head">
|
||||
<h2>품목 라인</h2>
|
||||
<span class="erp-muted">
|
||||
{% if search_enabled %}제품코드 검색 → 제품명 자동 채움.{% else %}상품 검색 비활성(수동 입력).{% endif %}
|
||||
수량 입력 시 박스 수 자동 계산.
|
||||
제품명 선택 시 제품코드 자동 입력. 수량 입력 시 박스 수 자동 계산.
|
||||
{% if not products %}<a href="/cupang/products">설정에서 제품명 먼저 등록</a>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<table class="erp-table cpg-lines">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>제품코드</th><th>제품명</th><th>수량</th>
|
||||
<th>#</th><th>제품명</th><th>제품코드</th><th>수량</th>
|
||||
<th>입수량</th><th>박스 계산</th><th>라인메모</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -110,6 +110,9 @@
|
||||
<script type="application/json" id="cpg-box-rules">
|
||||
{{ box_rules | tojson }}
|
||||
</script>
|
||||
<script type="application/json" id="cpg-products">
|
||||
{{ products | tojson }}
|
||||
</script>
|
||||
<script>
|
||||
window.CPG = {
|
||||
searchEnabled: {{ 'true' if search_enabled else 'false' }},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<!-- 페이지 액션 -->
|
||||
<div class="erp-page-actions cpg-actions">
|
||||
<a class="erp-btn erp-btn-primary" href="/cupang/new">+ 신규 등록</a>
|
||||
<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/export?year={{ year }}&month={{ month }}">엑셀({{ month }}월)</a>
|
||||
@@ -34,7 +35,8 @@
|
||||
<a class="cpg-cal-cell
|
||||
{% if not cell.in_month %}cpg-out{% endif %}
|
||||
{% if cell.is_today %}cpg-today{% endif %}
|
||||
{% if cell.is_selected %}cpg-selected{% endif %}"
|
||||
{% if cell.is_selected %}cpg-selected{% endif %}
|
||||
{% if cell.is_sunday or cell.is_holiday %}cpg-red{% elif cell.is_saturday %}cpg-blue{% endif %}"
|
||||
href="/cupang/?year={{ year }}&month={{ month }}&date={{ cell.date }}">
|
||||
<span class="cpg-cal-day">{{ cell.day }}</span>
|
||||
<span class="cpg-cal-badges">
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-outline" href="/cupang/">← 달력</a>
|
||||
</div>
|
||||
|
||||
<!-- itemcode_db 검색 → 등록 -->
|
||||
<div class="erp-card cpg-form-card">
|
||||
<div class="cpg-card-head">
|
||||
<h2>itemcode_db 에서 제품 검색·등록</h2>
|
||||
<span class="erp-muted">
|
||||
{% if search_enabled %}제품코드/제품명으로 검색 후 "등록".{% else %}
|
||||
상품 검색 비활성: {{ search_reason }} — 아래에서 수동 등록하세요.{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if search_enabled %}
|
||||
<div class="cpg-inline-form">
|
||||
<input class="erp-input" type="text" id="cpg-prod-q" placeholder="제품코드 또는 제품명" style="min-width:240px" />
|
||||
<button type="button" class="erp-btn erp-btn-outline" id="cpg-prod-search">검색</button>
|
||||
</div>
|
||||
<div id="cpg-prod-results" class="cpg-prod-results"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 수동 등록 -->
|
||||
<div class="erp-card cpg-form-card">
|
||||
<div class="cpg-card-head"><h2>제품명 수동 등록 / 수정</h2>
|
||||
<span class="erp-muted">같은 제품코드는 덮어씁니다.</span></div>
|
||||
<form method="post" action="/cupang/products" class="cpg-rule-grid">
|
||||
<label class="erp-field"><span>제품코드 *</span>
|
||||
<input class="erp-input" type="text" name="product_code" required placeholder="예: MS-1001" /></label>
|
||||
<label class="erp-field"><span>제품명 *</span>
|
||||
<input class="erp-input" type="text" name="product_name" required placeholder="예: 미라네 1호세트" /></label>
|
||||
<label class="erp-field"><span>정렬</span>
|
||||
<input class="erp-input" type="number" name="sort_order" value="0" /></label>
|
||||
<div><button type="submit" class="erp-btn erp-btn-primary">등록</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 등록된 제품명 목록 -->
|
||||
<div class="erp-card cpg-form-card">
|
||||
<div class="cpg-card-head"><h2>등록된 제품명 ({{ products|length }})</h2>
|
||||
<span class="erp-muted">폼의 제품명 드롭다운에 노출됩니다.</span></div>
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table">
|
||||
<thead><tr><th>제품명</th><th>제품코드</th><th>정렬</th><th>상태</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in products %}
|
||||
<tr {% if not p.active %}style="opacity:.55"{% endif %}>
|
||||
<td>{{ p.product_name }}</td>
|
||||
<td>{{ p.product_code }}</td>
|
||||
<td>{{ p.sort_order }}</td>
|
||||
<td>{% if p.active %}<span class="erp-badge erp-badge-success">활성</span>{% else %}<span class="erp-badge erp-badge-neutral">비활성</span>{% endif %}</td>
|
||||
<td>
|
||||
{% if p.active %}
|
||||
<form method="post" action="/cupang/products/{{ p.id }}/delete" class="cpg-inline-form"
|
||||
onsubmit="return confirm('비활성화합니다. 계속할까요?');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">비활성화</button>
|
||||
</form>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{% if search_enabled %}
|
||||
<script>
|
||||
(function () {
|
||||
var q = document.getElementById("cpg-prod-q");
|
||||
var btn = document.getElementById("cpg-prod-search");
|
||||
var box = document.getElementById("cpg-prod-results");
|
||||
function esc(s){ var d=document.createElement("div"); d.textContent=s||""; return d.innerHTML; }
|
||||
function run() {
|
||||
var term = (q.value || "").trim();
|
||||
if (!term) { box.innerHTML = ""; return; }
|
||||
box.innerHTML = '<p class="erp-muted">검색중…</p>';
|
||||
fetch("/cupang/api/products/search?q=" + encodeURIComponent(term))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var rows = (data && data.results) || [];
|
||||
if (!rows.length) { box.innerHTML = '<p class="erp-muted">결과 없음</p>'; return; }
|
||||
var html = '<table class="erp-table"><thead><tr><th>제품코드</th><th>제품명</th><th>구분</th><th></th></tr></thead><tbody>';
|
||||
rows.forEach(function (it) {
|
||||
html += '<tr><td>' + esc(it.code) + '</td><td>' + esc(it.name) + '</td><td>' + esc(it.type) + '</td>' +
|
||||
'<td><form method="post" action="/cupang/products" class="cpg-inline-form">' +
|
||||
'<input type="hidden" name="product_code" value="' + esc(it.code) + '">' +
|
||||
'<input type="hidden" name="product_name" value="' + esc(it.name) + '">' +
|
||||
'<button type="submit" class="erp-btn erp-btn-outline">등록</button></form></td></tr>';
|
||||
});
|
||||
html += "</tbody></table>";
|
||||
box.innerHTML = html;
|
||||
})
|
||||
.catch(function () { box.innerHTML = '<p class="erp-muted">검색 실패</p>'; });
|
||||
}
|
||||
btn.addEventListener("click", run);
|
||||
q.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); run(); } });
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user