From 3537e058b0b8b5a3dbd8339ecc89f489c299060b Mon Sep 17 00:00:00 2001 From: king Date: Wed, 19 Aug 2026 18:35:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(cafe24):=20=ED=8E=B8=EC=A7=91=EA=B8=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=83=81=ED=92=88=EB=AA=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제목 옆 연필 버튼 → 입력칸 → 저장. 이름만 바꾸려고 카페24 관리자에 들어갈 필요가 없어진다. - POST /cafe24/products/{no}/name (JSON) 추가. 쓰기 전에 카페24 현재값을 읽어 이전 이름을 감사로그(rename_product)에 남긴다 - 되돌릴 revision 이 없으므로 로그가 유일한 복구 단서다. 값이 같으면 호출하지 않는다. - build_update_payload/update_product 에 product_name 추가(부분 수정이라 상세설명·진열·판매는 그대로). - 빈 값과 250자 초과는 서버에서 400. 화면도 maxlength 로 막는다. - 평소에는 읽기 전용 제목이고 연필을 눌러야 입력칸이 된다 - 클릭 한 번으로 실수로 고쳐지지 않게. Enter 저장, Esc 취소. - 화면은 요청값이 아니라 카페24가 확인해 준 이름으로 다시 그리고, 왼쪽 목록의 이름·정렬키(data-name)도 함께 갱신한다. Co-Authored-By: Claude Opus 5 --- app/integrations/cafe24/products.py | 7 +- app/modules/cafe24/routes_products.py | 61 ++++++++++++++ .../cafe24/templates/cafe24/_editor.html | 21 ++++- .../cafe24/templates/cafe24/products.html | 83 ++++++++++++++++++- app/static/cafe24.css | 50 +++++++++++ docs/CAFE24_MODULE.md | 1 + 6 files changed, 219 insertions(+), 4 deletions(-) diff --git a/app/integrations/cafe24/products.py b/app/integrations/cafe24/products.py index 67611d8..9905ece 100644 --- a/app/integrations/cafe24/products.py +++ b/app/integrations/cafe24/products.py @@ -158,6 +158,7 @@ def build_update_payload( *, description: str | None = None, mobile_description: str | None = None, + product_name: str | None = None, display: bool | None = None, selling: bool | None = None, shop_no: int | None = None, @@ -171,6 +172,8 @@ def build_update_payload( request: dict[str, Any] = {} if description is not None: request["description"] = description + if product_name is not None: + request["product_name"] = product_name if mobile_description is not None: request["mobile_description"] = mobile_description if display is not None: @@ -189,11 +192,12 @@ def update_product( *, description: str | None = None, mobile_description: str | None = None, + product_name: str | None = None, display: bool | None = None, selling: bool | None = None, shop_no: int | None = None, ) -> dict[str, Any]: - """상품 부분 수정. 상세설명·진열·판매를 한 번의 호출로 바꿀 수 있다. + """상품 부분 수정. 상세설명·상품명·진열·판매를 한 번의 호출로 바꿀 수 있다. 바꿀 것이 하나도 없으면 호출하지 않고 빈 dict 를 돌려준다. @@ -203,6 +207,7 @@ def update_product( payload = build_update_payload( description=description, mobile_description=mobile_description, + product_name=product_name, display=display, selling=selling, shop_no=shop_no, diff --git a/app/modules/cafe24/routes_products.py b/app/modules/cafe24/routes_products.py index 35b1a5c..d767164 100644 --- a/app/modules/cafe24/routes_products.py +++ b/app/modules/cafe24/routes_products.py @@ -229,6 +229,67 @@ def product_pane(request: Request, product_no: int) -> HTMLResponse: return _no_store(render_template(request, "cafe24/_editor.html", ctx)) +# 카페24 상품명 최대 길이(API 문서 기준). 넘기면 카페24가 거절하므로 미리 막는다. +NAME_MAX = 250 + + +@products_router.post("/products/{product_no}/name") +def product_rename( + request: Request, + product_no: int, + payload: dict[str, Any] = Body(default_factory=dict), +) -> dict[str, Any]: + """상품명만 바꾼다 — 편집기 제목 옆 연필 버튼용(JSON API). + + 상세설명과 마찬가지로 **쓰기 전에 카페24의 현재값을 읽는다.** 여기서는 되돌릴 + HTML 이 없으므로 revision 은 만들지 않고, 대신 이전 이름을 감사로그에 남긴다 + (되돌리려면 로그를 보고 다시 바꾼다). + """ + st, user = require_store(request) + actor = str(user.get("email") or "") + + name = str(payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="상품명을 입력하세요.") + if len(name) > NAME_MAX: + raise HTTPException(status_code=400, detail=f"상품명은 {NAME_MAX}자를 넘을 수 없습니다.") + + api = build_cafe24_api(st) + try: + current = products.get_product(api.client, product_no) + except Cafe24Error as exc: + st.log_audit( + actor=actor, action="rename_product", product_no=product_no, + result="FAIL", detail=f"현재값 조회 실패: {exc}", + ) + raise HTTPException(status_code=502, detail=f"카페24 현재값을 읽지 못했습니다: {exc}") from exc + + before = str(current.get("product_name") or "") + if before == name: + return {"ok": True, "product_name": before, "changed": False} + + try: + updated = products.update_product(api.client, product_no, product_name=name) + except Cafe24Error as exc: + st.log_audit( + actor=actor, action="rename_product", product_no=product_no, + result="FAIL", detail=f"'{before}' → '{name}' 실패: {exc}", + ) + logger.warning("카페24 상품 %s 이름 변경 실패: %s", product_no, exc) + raise HTTPException(status_code=502, detail=str(exc)) from exc + + info = products.normalize_product(updated) if updated else {} + if info.get("product_no"): + st.upsert_products([info]) + after = str(info.get("product_name") or name) + st.log_audit( + actor=actor, action="rename_product", product_no=product_no, + result="SUCCESS", detail=f"'{before}' → '{after}'", + ) + logger.info("카페24 상품 %s 이름 변경 (%s)", product_no, actor) + return {"ok": True, "product_name": after, "changed": True} + + @products_router.post("/products/{product_no}/status") def product_status( request: Request, diff --git a/app/modules/cafe24/templates/cafe24/_editor.html b/app/modules/cafe24/templates/cafe24/_editor.html index 1df77f5..584ea87 100644 --- a/app/modules/cafe24/templates/cafe24/_editor.html +++ b/app/modules/cafe24/templates/cafe24/_editor.html @@ -12,8 +12,25 @@ {% endif %}
-
-

{{ info.product_name or '상품' }}

+ {# 제목 = 상품명. 연필 버튼을 누르면 입력칸으로 바뀐다(평소에는 읽기 전용 — + 클릭만으로 실수로 고쳐지지 않게). 저장은 JS 가 POST /products/{no}/name. + 카페24 조회에 실패했을 때(desc 없음)는 현재 이름을 믿을 수 없어 버튼을 뺀다. #} +
+

+ {{ info.product_name or '상품' }} + {% if desc %} + + {% endif %} +

+ {% if desc %} + + {% endif %}

상품번호 {{ product_no }} {% if info.product_code %}· {{ info.product_code }}{% endif %} diff --git a/app/modules/cafe24/templates/cafe24/products.html b/app/modules/cafe24/templates/cafe24/products.html index aed3757..915b31a 100644 --- a/app/modules/cafe24/templates/cafe24/products.html +++ b/app/modules/cafe24/templates/cafe24/products.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} {% block content %} @@ -313,6 +313,87 @@ }); } + // ── 상품명 수정 ── + // 연필 → 입력칸, 저장 시 POST /products/{no}/name. 서버가 쓰기 전에 카페24 + // 현재값을 읽어 이전 이름을 감사로그에 남긴다. + (function setupRename() { + var box = pane.querySelector("#cf24-name"); + var form = box && box.querySelector("#cf24-name-form"); + if (!box || !form) return; // 조회 실패 시엔 수정 버튼 자체가 없다 + var no = box.dataset.productNo; + var view = box.querySelector("#cf24-name-view"); + var text = box.querySelector("#cf24-name-text"); + var input = box.querySelector("#cf24-name-input"); + var saveBtn = box.querySelector("#cf24-name-save"); + var cancelBtn = box.querySelector("#cf24-name-cancel"); + + function open(on) { + view.classList.toggle("is-hidden", on); + form.classList.toggle("is-hidden", !on); + if (on) { input.value = text.textContent; input.focus(); input.select(); } + } + + // 왼쪽 목록도 다시 받지 않으므로 같은 상품 행의 이름·정렬키를 직접 맞춘다. + function paintRow(name) { + var tr = document.querySelector('#cf24-list tr.cf24-row[data-no="' + no + '"]'); + if (!tr) return; + tr.dataset.name = name; + var cell = tr.querySelector(".cf24-col-name"); + if (!cell) return; + cell.title = name; + var link = cell.querySelector("a"); + (link || cell).textContent = name; + } + + function busy(state) { + saveBtn.disabled = state; + cancelBtn.disabled = state; + input.disabled = state; + } + + function save() { + var name = input.value.trim(); + if (!name) { window.alert("상품명을 입력하세요."); input.focus(); return; } + if (name === text.textContent) { open(false); return; } + if (!window.confirm("상품명을 「" + name + "」(으)로 바꿉니다.\n카페24 쇼핑몰에 바로 반영됩니다. 계속할까요?")) return; + + busy(true); + fetch("/cafe24/products/" + no + "/name", { + method: "POST", + credentials: "same-origin", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name }) + }) + .then(function (res) { + return res.json().catch(function () { return {}; }).then(function (data) { + if (!res.ok) throw new Error(data.detail || "HTTP " + res.status); + return data; + }); + }) + .then(function (data) { + // 화면은 요청값이 아니라 카페24가 확인해 준 이름으로 그린다. + var applied = data.product_name || name; + text.textContent = applied; + input.value = applied; + paintRow(applied); + open(false); + }) + .catch(function (err) { + window.alert("상품명 변경에 실패했습니다: " + err.message); + }) + .then(function () { busy(false); }); + } + + box.querySelector("#cf24-name-edit").addEventListener("click", function () { open(true); }); + cancelBtn.addEventListener("click", function () { open(false); }); + saveBtn.addEventListener("click", save); + input.addEventListener("keydown", function (e) { + if (e.key === "Enter") { e.preventDefault(); save(); } + else if (e.key === "Escape") { e.preventDefault(); open(false); } + }); + })(); + // ── 진열/판매 배지 클릭 = 상태 토글 ── // 쓰기는 서버가 한다(POST /products/{no}/status). 화면은 **응답에 담긴 실제 // 상태**로 다시 그린다 — 요청값을 낙관적으로 반영하면 실패했을 때 화면과 diff --git a/app/static/cafe24.css b/app/static/cafe24.css index 8ed49e0..6188522 100644 --- a/app/static/cafe24.css +++ b/app/static/cafe24.css @@ -205,6 +205,56 @@ letter-spacing: -0.45px; } +/* 상품명 수정 — 평소엔 제목, 연필을 누르면 입력칸으로 바뀐다 */ +/* 입력칸이 남는 폭을 다 쓰게 한다(min-width:0 이 없으면 긴 이름이 배지를 밀어낸다) */ +.cf24-name-box { + flex: 1 1 auto; + min-width: 0; +} + +.cf24-name-box .is-hidden { + display: none; +} + +.cf24-name-edit { + margin-left: var(--sp-6, 6px); + padding: 0 4px; + border: 0; + border-radius: var(--r-sm, 4px); + background: transparent; + color: var(--color-midtone-gray, #737373); + font-size: var(--text-body, 14px); + line-height: 1.4; + cursor: pointer; + vertical-align: middle; +} + +.cf24-name-edit:hover { + background: var(--color-ghost-gray, #f2f2f2); + color: var(--color-rich-black, #0a0a0a); +} + +.cf24-name-form { + display: flex; + align-items: center; + gap: var(--sp-6, 6px); +} + +.cf24-name-input { + flex: 1 1 auto; + min-width: 200px; + padding: var(--sp-6, 6px) var(--sp-8, 8px); + border: 1px solid var(--color-subtle-ash, #e5e5e5); + border-radius: var(--r-md, 6px); + font-size: var(--text-heading, 18px); + letter-spacing: -0.45px; +} + +.cf24-name-input:focus { + outline: none; + border-color: var(--color-rich-black, #0a0a0a); +} + .cf24-editor-sub { margin: 4px 0 0; font-size: var(--text-caption, 12px); diff --git a/docs/CAFE24_MODULE.md b/docs/CAFE24_MODULE.md index 932666c..558036b 100644 --- a/docs/CAFE24_MODULE.md +++ b/docs/CAFE24_MODULE.md @@ -56,6 +56,7 @@ app/modules/cafe24/ ← 상품관리 모듈 | `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` | | `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` | | `POST /cafe24/products/{product_no}/status` | 진열/판매 토글 (JSON: `{field, value}` → 적용 후 상태) | `cafe24` | +| `POST /cafe24/products/{product_no}/name` | 상품명 변경 (JSON: `{name}` → 적용된 이름). 이전 이름은 감사로그 `rename_product` | `cafe24` | | `GET /cafe24/schedules` | 예약 목록 · 취소 | `cafe24` | | `POST /cafe24/schedules` | 예약 등록 (편집기에서) | `cafe24` | | `POST /cafe24/schedules/{id}/cancel` | 대기 중 예약 취소 | `cafe24` |