From 34c7e2f9643ea4f404297cc3f60edfc41f956f57 Mon Sep 17 00:00:00 2001 From: king Date: Sat, 30 May 2026 03:24:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(cupang):=20=EC=A0=9C=ED=92=88=EB=AA=85=20?= =?UTF-8?q?=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=20=EC=84=A4=EC=A0=95=20+?= =?UTF-8?q?=20=EB=8B=AC=EB=A0=A5=20=EA=B3=B5=ED=9C=B4=EC=9D=BC=20=EC=83=89?= =?UTF-8?q?=EC=83=81=20+=20=ED=8F=BC=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설정 화면 추가: /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 --- app/modules/cupang/db.py | 67 +++++++ app/modules/cupang/holidays.py | 62 +++++++ app/modules/cupang/router.py | 70 +++++++ app/modules/cupang/templates/cupang/form.html | 11 +- .../cupang/templates/cupang/index.html | 4 +- .../cupang/templates/cupang/products.html | 111 ++++++++++++ app/static/cupang.css | 45 +++-- app/static/cupang.js | 171 +++++++++--------- scripts/sql/cupang_db_init.sql | 22 ++- 9 files changed, 457 insertions(+), 106 deletions(-) create mode 100644 app/modules/cupang/holidays.py create mode 100644 app/modules/cupang/templates/cupang/products.html 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 '' }}" />