diff --git a/app/modules/cupang/db.py b/app/modules/cupang/db.py
index c4fbaac..182ceae 100644
--- a/app/modules/cupang/db.py
+++ b/app/modules/cupang/db.py
@@ -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:
diff --git a/app/modules/cupang/holidays.py b/app/modules/cupang/holidays.py
new file mode 100644
index 0000000..e47f7f7
--- /dev/null
+++ b/app/modules/cupang/holidays.py
@@ -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())
diff --git a/app/modules/cupang/router.py b/app/modules/cupang/router.py
index 65bad2f..ed31f53 100644
--- a/app/modules/cupang/router.py
+++ b/app/modules/cupang/router.py
@@ -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 읽기 전용) + 박스 계산 미리보기
# ════════════════════════════════════════════════════════════
diff --git a/app/modules/cupang/templates/cupang/form.html b/app/modules/cupang/templates/cupang/form.html
index fc9c43f..29bc984 100644
--- a/app/modules/cupang/templates/cupang/form.html
+++ b/app/modules/cupang/templates/cupang/form.html
@@ -55,7 +55,7 @@
+ value="{{ shipment.document_no or '' if shipment else '' }}" />