From 1d33761003277f3a087f8e1f5c584c14e047e406 Mon Sep 17 00:00:00 2001 From: king Date: Sat, 30 May 2026 03:46:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(cupang):=20=EC=84=A4=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=202=EC=97=B4=20=EA=B0=9C=ED=8E=B8=20=E2=80=94=20itemc?= =?UTF-8?q?ode=20=EB=8B=A4=EC=A4=91=EC=84=A0=ED=83=9D=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=20+=20=ED=99=9C=EC=84=B1/=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수동 등록 카드 제거 - 왼쪽: itemcode_db 전체 목록(낱개+세트) 다중선택, 등록된 항목 진회색+흰글씨 /cupang/api/products/all + /cupang/products/bulk(JSON 일괄 upsert) - 오른쪽: 등록 제품명 목록 — 제품명/코드/상태 + 활성·비활성 토글 + 완전삭제 /products/{id}/active (토글), /products/{id}/delete (hard delete) - ItemcodeReader.list_all 추가 Co-Authored-By: Claude Opus 4.8 --- app/modules/cupang/db.py | 6 +- app/modules/cupang/itemcode.py | 12 +- app/modules/cupang/router.py | 70 +++++- .../cupang/templates/cupang/products.html | 206 +++++++++++------- app/static/cupang.css | 29 +++ 5 files changed, 229 insertions(+), 94 deletions(-) diff --git a/app/modules/cupang/db.py b/app/modules/cupang/db.py index 182ceae..a5aa4f6 100644 --- a/app/modules/cupang/db.py +++ b/app/modules/cupang/db.py @@ -237,11 +237,11 @@ class CupangDBStore: ).fetchone() return self._product_serialize(row) - def deactivate_product(self, *, product_id: int) -> None: + def set_product_active(self, *, product_id: int, active: bool) -> None: with self._pool.connection() as conn: cur = conn.execute( - "UPDATE cupang_products SET active = FALSE WHERE id = %s", - (product_id,), + "UPDATE cupang_products SET active = %s WHERE id = %s", + (bool(active), product_id), ) if cur.rowcount == 0: raise KeyError(product_id) diff --git a/app/modules/cupang/itemcode.py b/app/modules/cupang/itemcode.py index a93bb3d..d5520d1 100644 --- a/app/modules/cupang/itemcode.py +++ b/app/modules/cupang/itemcode.py @@ -114,9 +114,17 @@ class ItemcodeReader: 비활성 상태이거나 조회 실패 시 빈 리스트(예외 비전파 — UI 는 수동 입력 폴백). """ q = (query or "").strip() - if not self.enabled or not self._pool or not self._sql or not q: + if not q: + return [] + return self._run(f"%{q}%", limit) + + def list_all(self, *, limit: int = 2000) -> list[dict[str, Any]]: + """전체 상품 목록(낱개+세트). 설정 화면 왼쪽 리스트용.""" + return self._run("%", limit) + + def _run(self, like: str, limit: int) -> list[dict[str, Any]]: + if not self.enabled or not self._pool or not self._sql: return [] - like = f"%{q}%" try: with self._pool.connection() as conn: rows = conn.execute( diff --git a/app/modules/cupang/router.py b/app/modules/cupang/router.py index ed31f53..82e909b 100644 --- a/app/modules/cupang/router.py +++ b/app/modules/cupang/router.py @@ -15,7 +15,7 @@ import json from datetime import date, datetime from typing import Any -from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request from fastapi.responses import ( HTMLResponse, JSONResponse, @@ -609,14 +609,54 @@ async def products_page(request: Request) -> HTMLResponse: "is_admin": is_admin(user), "nav_items": build_erp_nav(user, active="cupang"), "page_title": "쿠팡 밀크런 — 설정 (제품명)", - "page_subtitle": "제품명 리스트 관리. itemcode_db 에서 검색해 등록하면 폼 드롭다운에 노출됩니다.", + "page_subtitle": "왼쪽 itemcode_db 목록에서 선택해 등록하면 폼 드롭다운에 노출됩니다.", "products": store.list_products(include_inactive=True), + "registered_codes": [p["product_code"] for p in store.list_products(include_inactive=True)], "search_enabled": bool(reader and reader.enabled), "search_reason": (reader.reason if reader else ""), }, ) +@router.get("/api/products/all") +async def product_all( + request: Request, _: dict[str, Any] = Depends(_require_user) +) -> JSONResponse: + """itemcode_db 전체 상품 목록(낱개+세트). 설정 화면 왼쪽 리스트 소스.""" + reader = _itemcode(request) + return JSONResponse( + { + "enabled": bool(reader and reader.enabled), + "reason": (reader.reason if reader else "itemcode 리더 미초기화"), + "results": reader.list_all() if reader else [], + } + ) + + +@router.post("/products/bulk") +async def product_bulk( + request: Request, + items: list[dict[str, Any]] = Body(..., embed=True), + user: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + """선택한 상품들을 일괄 등록(upsert). body: {"items":[{"code","name"}, ...]}.""" + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="cupang_db 미설정") + added = 0 + for it in items: + code = str(it.get("code") or "").strip() + name = str(it.get("name") or "").strip() + if not code or not name: + continue + try: + store.upsert_product(product_code=code, product_name=name) + added += 1 + except ValueError: + continue + return JSONResponse({"ok": True, "added": added}) + + @router.post("/products") async def product_upsert( request: Request, @@ -637,17 +677,37 @@ async def product_upsert( return RedirectResponse(url="/cupang/products", status_code=303) -@router.post("/products/{product_id:int}/delete") -async def product_delete( +@router.post("/products/{product_id:int}/active") +async def product_set_active( request: Request, product_id: int, + active: 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 미설정") try: - store.deactivate_product(product_id=product_id) + store.set_product_active( + product_id=product_id, active=active in ("1", "true", "on", "True") + ) + except KeyError: + raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.") + 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: + """완전 삭제(hard delete).""" + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="cupang_db 미설정") + try: + store.delete_product(product_id=product_id) except KeyError: raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.") return RedirectResponse(url="/cupang/products", status_code=303) diff --git a/app/modules/cupang/templates/cupang/products.html b/app/modules/cupang/templates/cupang/products.html index 6201f17..c1bc9fb 100644 --- a/app/modules/cupang/templates/cupang/products.html +++ b/app/modules/cupang/templates/cupang/products.html @@ -9,102 +9,140 @@ ← 달력 - -
-
-

itemcode_db 에서 제품 검색·등록

- - {% if search_enabled %}제품코드/제품명으로 검색 후 "등록".{% else %} - 상품 검색 비활성: {{ search_reason }} — 아래에서 수동 등록하세요.{% endif %} - +
+ + +
+
+

itemcode_db 상품

+ + {% if search_enabled %}선택(다중) 후 등록. 이미 등록된 항목은 진한 회색.{% else %} + 검색 비활성: {{ search_reason }}{% endif %} + +
+ + {% if search_enabled %} +
+ + +
+

불러오는 중…

+ {% else %} +

ITEMCODE_DB_URL / ITEMCODE_SEARCH_SQL 설정 후 사용 가능합니다.

+ {% endif %}
- {% if search_enabled %} -
- - + +
+

등록된 제품명 ({{ products|length }})

+ 폼의 제품명 드롭다운에 노출
+
+ + + + {% for p in products %} + + + + + + + {% endfor %} + {% if not products %} + + {% endif %} + +
제품명제품코드상태동작
{{ p.product_name }}{{ p.product_code }}{% if p.active %}활성{% else %}비활성{% endif %} +
+ {% if p.active %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+ +
+
+
등록된 제품명이 없습니다. 왼쪽에서 선택해 등록하세요.
+
-
- {% endif %} -
- -
-

제품명 수동 등록 / 수정

- 같은 제품코드는 덮어씁니다.
-
- - - -
-
- - -
-

등록된 제품명 ({{ products|length }})

- 폼의 제품명 드롭다운에 노출됩니다.
-
- - - - {% for p in products %} - - - - - - - - {% endfor %} - -
제품명제품코드정렬상태
{{ p.product_name }}{{ p.product_code }}{{ p.sort_order }}{% if p.active %}활성{% else %}비활성{% endif %} - {% if p.active %} -
- -
- {% else %}—{% endif %} -
-
-
- {% if search_enabled %} + {% endif %} diff --git a/app/static/cupang.css b/app/static/cupang.css index d6b8425..7303043 100644 --- a/app/static/cupang.css +++ b/app/static/cupang.css @@ -118,6 +118,35 @@ .cpg-detail-grid dt { font-size: 12px; color: var(--color-midtone-gray); } .cpg-detail-grid dd { margin: 2px 0 0; font-size: 14px; } +/* ── 설정(제품명) 2열 레이아웃 ── */ +.cpg-prod-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; align-items: start; +} +@media (max-width: 980px) { .cpg-prod-layout { grid-template-columns: 1fr; } } + +.cpg-src-list { + max-height: 520px; overflow-y: auto; + border: 1px solid var(--color-subtle-ash); border-radius: 10px; +} +.cpg-src-item { + display: flex; align-items: baseline; justify-content: space-between; gap: 10px; + padding: 8px 12px; cursor: pointer; + border-bottom: 1px solid var(--color-subtle-ash); +} +.cpg-src-item:last-child { border-bottom: 0; } +.cpg-src-item:hover { background: var(--color-ghost-gray); } +.cpg-src-name { font-size: 14px; font-weight: 500; } +.cpg-src-code { font-size: 12px; color: var(--color-midtone-gray); white-space: nowrap; } +/* 선택됨: 검정 테두리 강조 */ +.cpg-src-item.is-selected { box-shadow: inset 0 0 0 2px var(--color-deep-black); } +/* 이미 등록됨: 진한 회색 배경 + 흰 글씨 */ +.cpg-src-item.cpg-registered { background: #4b4b4b; } +.cpg-src-item.cpg-registered .cpg-src-name { color: #fff; } +.cpg-src-item.cpg-registered .cpg-src-code { color: #d4d4d4; } +.cpg-src-item.cpg-registered:hover { background: #3a3a3a; } + /* ── 상품 검색 드롭다운 ── */ .cpg-search-pop { position: absolute; z-index: 50;