feat(cafe24): 품목 추가·삭제 + 옵션 재생성

- 추가(「+ 행 추가」/불러오기의 새 이름): 저장 시 옵션값을 끝에 덧붙여
  PUT options(allow_append — 앞 위치는 그대로라 기존 이름이 밀리지 않음).
  카페24가 품목을 자동 생성하면 wait_for_variants 로 새 코드를 받아
  자체코드·추가금액·진열/판매를 이어서 PUT. 거부 시 오류 그대로 표시.
- 삭제(행 ✕): DELETE /variants/{code}. 지연 중 GET 에 남는 삭제 품목은
  스냅샷 {"_deleted": true} 로 걸러냄. 품목 없는 옵션값은 하단 안내로 분리.
- 옵션값 줄이기(개수 감소)는 서버가 거부 — 위치 짝맞춤으로 이름이 밀림.
- 옵션 재생성: 전체 삭제 → 현재 행으로 생성 → 코드/금액/썸네일 재반영
  (품목코드 새로 부여, 강한 확인창).
- 유닛 91 통과, 통합 하네스(덧붙이기·삭제·지연 필터) 통과, 헤드리스 렌더 확인.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:47:42 +09:00
parent f31204e9b9
commit 85c7333e72
7 changed files with 445 additions and 171 deletions
+45 -3
View File
@@ -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,
+28 -5
View File
@@ -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}
@@ -125,7 +125,7 @@
{% endblock %}
{% block scripts %}
<script src="/static/cafe24-options.js?v=20260919a"></script>
<script src="/static/cafe24-options.js?v=20260919b"></script>
<script>
(function () {
var pane = document.getElementById("cf24-editor-pane");
+25
View File
@@ -1075,6 +1075,31 @@ def test_build_update_options_request_rejects_count_mismatch():
pass
else:
raise AssertionError(edited)
# 덧붙이기는 allow_append 일 때만
more = [{"option_name": "A", "option_value": [{"option_text": "1"}, {"option_text": "2", "option_image_file": "https://d/2.png"}]}]
try:
store.build_update_options_request(original, more)
except ValueError:
pass
else:
raise AssertionError("append without flag accepted")
body = store.build_update_options_request(original, more, allow_append=True)
assert body["original_options"][0]["option_value"] == [{"option_text": "1"}]
assert body["options"][0]["option_value"] == [{"option_text": "1"}, {"option_text": "2", "option_image_file": "https://d/2.png"}]
# 줄이기는 allow_append 여도 거부 (위치 짝맞춤으로 이름이 밀린다)
two = [{"option_name": "A", "option_value": [{"option_text": "1"}, {"option_text": "2"}]}]
try:
store.build_update_options_request(two, original, allow_append=True)
except ValueError:
pass
else:
raise AssertionError("shrink accepted")
def test_delete_variant_wrapper():
client = _RouteClient({"DELETE /admin/products/7/variants/P000000R000A": {"variant": {"variant_code": "P000000R000A"}}})
products.delete_variant(client, 7, "p000000r000a")
assert client.calls[-1][:2] == ("DELETE", "/admin/products/7/variants/P000000R000A")
def test_build_variant_updates():