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 '' }}" />