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 %}
상품번호 {{ 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` |