diff --git a/app/integrations/cafe24/products.py b/app/integrations/cafe24/products.py index c2ba18c..98fd8af 100644 --- a/app/integrations/cafe24/products.py +++ b/app/integrations/cafe24/products.py @@ -402,6 +402,14 @@ def list_variants(client: Cafe24Client, product_no: int) -> list[dict[str, Any]] return variants if isinstance(variants, list) else [] +def delete_variant(client: Cafe24Client, product_no: int, variant_code: str) -> dict[str, Any]: + """품목 1건 삭제 — DELETE /admin/products/{no}/variants/{code} (문서에 있는 유일한 + 품목 제거 수단). 옵션값 자체는 남을 수 있다(품목 없는 옵션값).""" + no = int(product_no) + code = str(variant_code or "").strip().upper() + return client.delete(f"/admin/products/{no}/variants/{code}", product_no=no) + + def wait_for_variants( client: Cafe24Client, product_no: int, diff --git a/app/modules/cafe24/routes_product_info.py b/app/modules/cafe24/routes_product_info.py index 3026b28..d86535a 100644 --- a/app/modules/cafe24/routes_product_info.py +++ b/app/modules/cafe24/routes_product_info.py @@ -248,7 +248,10 @@ def _options_view(option: dict[str, Any]) -> dict[str, Any]: def _apply_variant_snapshot(st: Any, product_no: int, variants: list[dict[str, Any]]) -> list[dict[str, Any]]: - """GET 품목 목록에 유예시간 안의 우리 쓰기(스냅샷)를 덮어씌운다.""" + """GET 품목 목록에 유예시간 안의 우리 쓰기(스냅샷)를 덮어씌운다. + + 삭제한 품목(`{"_deleted": true}`)은 카페24 GET 이 한동안 계속 돌려주므로 걸러낸다. + """ section = (st.get_write_snapshot(product_no) or {}).get("variants") or {} data = section.get("data") or {} if not data or not store.within_grace(section.get("written_at"), grace_minutes=read_lag_grace_minutes()): @@ -257,6 +260,8 @@ def _apply_variant_snapshot(st: Any, product_no: int, variants: list[dict[str, A for v in variants: code = str(v.get("variant_code") or "") patch = data.get(code) + if isinstance(patch, dict) and patch.get("_deleted"): + continue out.append({**v, **patch} if isinstance(patch, dict) else v) return out @@ -363,12 +368,18 @@ def options_update( original = loaded["option"]["options"] try: body = store.build_update_options_request( - original, edited, option_list_type=str(payload.get("option_list_type") or "") + original, edited, option_list_type=str(payload.get("option_list_type") or ""), + allow_append=True, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if loaded["option"]["option_preset_code"]: body["option_preset_code"] = loaded["option"]["option_preset_code"] + # 뒤에 덧붙인 옵션값 수 — 카페24가 받아들이면 품목을 자동 생성한다(재시도 조회). + added = sum( + max(0, len(n["option_value"]) - len(o["option_value"])) + for o, n in zip(body["original_options"], body["options"]) + ) try: updated = products.update_options(api.client, product_no, body) @@ -382,10 +393,18 @@ def options_update( summary = "; ".join( f"{o['option_name']}: " + ", ".join(v["option_text"] for v in o["option_value"]) for o in body["options"] - ) + ) + (f" (+{added} 추가)" if added else "") st.log_audit(actor=actor, action="update_options", product_no=product_no, result="SUCCESS", detail=summary[:900]) logger.info("카페24 상품 %s 옵션 수정 (%s)", product_no, actor) + + if added: + # 새 옵션값의 품목코드를 받아 돌려준다 — 화면이 자체코드/추가금액을 이어서 PUT 한다. + fresh = products.wait_for_variants(api.client, product_no, len(loaded["variants"]) + added) + fresh = _apply_variant_snapshot(st, product_no, fresh) + return {"ok": True, "option": _options_view(updated), "variants": [_variant_view(v) for v in fresh], + "added": added} + # 품목의 옵션값 표기는 이름을 바꾼 만큼 달라진다 — 응답값으로 다시 맞춘다. variants = loaded["variants"] rename: dict[tuple[str, str], tuple[str, str]] = {} @@ -448,6 +467,29 @@ def option_image_upload( return {"ok": True, "path": path} +@product_info_router.delete("/products/{product_no}/variants/{variant_code}") +def variant_delete(request: Request, product_no: int, variant_code: str) -> dict[str, Any]: + """품목 1건 삭제. 옵션값은 남을 수 있다(카페24 모델) — 정리는 옵션 재생성으로.""" + st, user = require_store(request) + actor = str(user.get("email") or "") + code = str(variant_code or "").strip().upper() + if not store.VARIANT_CODE_RE.match(code): + raise HTTPException(status_code=400, detail=f"품목코드 형식이 올바르지 않습니다: {code}") + api = build_cafe24_api(st) + try: + products.delete_variant(api.client, product_no, code) + except Cafe24Error as exc: + st.log_audit(actor=actor, action="delete_variant", product_no=product_no, + result="FAIL", detail=f"{code}: {exc}") + raise _http_from_cafe24(exc) from exc + # 카페24 GET 은 한동안 삭제된 품목을 계속 돌려준다 — 스냅샷에 삭제 표식을 남겨 걸러낸다. + st.save_write_snapshot(product_no, "variants", {code: {"_deleted": True}}) + st.log_audit(actor=actor, action="delete_variant", product_no=product_no, + result="SUCCESS", detail=code) + logger.info("카페24 상품 %s 품목 %s 삭제 (%s)", product_no, code, actor) + return {"ok": True, "variant_code": code} + + @product_info_router.put("/products/{product_no}/variants") def variants_update( request: Request, diff --git a/app/modules/cafe24/store.py b/app/modules/cafe24/store.py index 9874cdf..bd7975b 100644 --- a/app/modules/cafe24/store.py +++ b/app/modules/cafe24/store.py @@ -691,13 +691,20 @@ def build_create_options_request( def build_update_options_request( - original: list[dict], edited: list[dict], *, option_list_type: str = "" + original: list[dict], + edited: list[dict], + *, + option_list_type: str = "", + allow_append: bool = False, ) -> dict: """옵션명/옵션값(이름·이미지·색상·표시방식) 수정 요청. 카페24 PUT options 는 `original_options`(수정 전) 와 `options`(수정 후) 를 - **같은 순서·같은 개수**로 받아 짝을 맞춘다. 옵션 항목 추가/삭제는 이 API 로 - 할 수 없으므로 개수가 다르면 거부한다. + **위치**로 짝지어 이름을 바꾼다. 그래서 + - 옵션값을 **뒤에 덧붙이는** 것(`allow_append`)만 허용한다 — 앞쪽 위치는 그대로라 + 기존 이름이 밀리지 않는다(카페24가 추가를 거부하면 그 오류가 그대로 올라온다). + - 옵션값 **삭제**(개수 감소)는 거부한다 — 중간을 빼면 뒤 값들이 한 칸씩 당겨져 + 엉뚱한 품목의 이름이 바뀐다. 정리는 옵션 재생성(삭제 후 생성)으로 한다. original: GET 응답의 options 그대로. edited: 화면에서 보낸 같은 모양의 목록. """ if len(original) != len(edited): @@ -707,10 +714,15 @@ def build_update_options_request( for o, e in zip(original, edited): o_vals = list(o.get("option_value") or []) e_vals = list(e.get("option_value") or []) - if len(o_vals) != len(e_vals): + if len(e_vals) < len(o_vals): + raise ValueError( + f"옵션 「{o.get('option_name')}」 의 옵션값을 줄일 수 없습니다. " + "옵션값 삭제는 카페24 API 가 지원하지 않습니다 — 옵션 재생성으로 정리하세요." + ) + if len(e_vals) > len(o_vals) and not allow_append: raise ValueError( f"옵션 「{o.get('option_name')}」 의 옵션값 개수가 카페24와 다릅니다. " - "옵션값 추가/삭제는 이 API 로 할 수 없습니다." + "다시 읽은 뒤 수정하세요." ) name = str(e.get("option_name") or "").strip() if not name: @@ -746,6 +758,17 @@ def build_update_options_request( n_item[key] = value orig_entry["option_value"].append(o_item) new_entry["option_value"].append(n_item) + # 덧붙이는 새 옵션값 — 수정 전 목록에는 없고 수정 후 목록 끝에만 있다. + for ev in e_vals[len(o_vals):]: + text = str(ev.get("option_text") or "").strip() + if not text: + raise ValueError("추가할 옵션값 이름은 비울 수 없습니다.") + n_item = {"option_text": text} + for key in ("option_image_file", "option_link_image", "option_color"): + value = str(ev.get(key) or "").strip() + if value: + n_item[key] = value + new_entry["option_value"].append(n_item) orig_out.append(orig_entry) new_out.append(new_entry) request: dict = {"original_options": orig_out, "options": new_out} diff --git a/app/modules/cafe24/templates/cafe24/products.html b/app/modules/cafe24/templates/cafe24/products.html index 98cdbab..9bc19d0 100644 --- a/app/modules/cafe24/templates/cafe24/products.html +++ b/app/modules/cafe24/templates/cafe24/products.html @@ -125,7 +125,7 @@ {% endblock %} {% block scripts %} - +