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
+8
View File
@@ -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,
+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():
+270 -108
View File
@@ -3,10 +3,17 @@
통합 목록: 옵션이 1개(옵션명 하나)인 상품은 옵션값과 품목이 1:1 이므로 한 행에
썸네일 · 옵션값 이름 · 품목코드 · 자체 품목코드 · 추가금액 · 진열 · 판매 를 놓고
「저장」 한 번으로 옵션(PUT options) → 품목(PUT variants) 순서로 반영한다.
행 순서는 품목 목록 순서(카페24가 진열순서대로 돌려준다 = 쇼핑몰 순서)를 따른다.
옵션이 없는 상품: 행을 자유롭게 추가/삭제해 두고 저장하면 옵션 생성(POST options,
카페24가 품목을 자동 생성) → 부여된 품목코드를 받아 → 자체코드/추가금액을 품목에
PUT → 썸네일이 있으면 옵션 PUT. 저장 뒤 다시 읽어 품목코드가 바로 보인다.
옵션이 2개 이상(조합)인 상품: 옵션값과 품목이 1:1 이 아니므로 예전 2열 화면
품목 추가: 「+ 행 추가」로 넣은 행은 저장 시 옵션값을 **뒤에 덧붙여** PUT 한다
(앞쪽 위치는 그대로라 기존 이름이 밀리지 않는다). 카페24가 품목을 자동 생성하면
그 코드로 자체코드/추가금액을 이어서 PUT 한다. 거부하면 오류를 그대로 보여준다.
품목 삭제: 행의 ✕ → DELETE /variants/{code} (문서에 있는 유일한 제거 수단). 옵션값
자체는 남을 수 있어(품목 없는 옵션값) 하단에 따로 보여주고, 정리는 「옵션 재생성」
(전체 삭제 → 현재 행으로 다시 생성 → 코드/금액/썸네일 재반영, 품목코드가 바뀜)으로.
옵션이 2개 이상(조합)인 상품: 옵션값과 품목이 1:1 이 아니므로 2열 화면
(왼쪽 옵션 정의 / 오른쪽 품목 표)을 그대로 쓴다.
순서 드래그: 품목의 display_order(카페24 문서 — 조합형 품목 진열순서)로 저장한다.
옵션값 배열 자체를 재배열해 PUT 하지 않는다 — 카페24는 original_options 와
@@ -31,7 +38,7 @@
var count = root.querySelector("#cf24-options-count");
if (!openBtn || !body) return;
var state = { option: null, variants: [], displayTypes: {} };
var state = { option: null, variants: [], displayTypes: {}, orphans: [] };
openBtn.addEventListener("click", function () {
var nameEl = document.getElementById("cf24-name-text");
@@ -80,6 +87,8 @@
function bindToggles(container) {
container.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
if (btn.dataset.bound) return;
btn.dataset.bound = "1";
btn.addEventListener("click", function () { paintToggle(btn, btn.dataset.on !== "1", false); });
});
}
@@ -105,6 +114,8 @@
function bindCopy(container) {
container.querySelectorAll(".cf24-v-copy").forEach(function (btn) {
if (btn.dataset.bound) return;
btn.dataset.bound = "1";
btn.addEventListener("click", function () {
copyText(btn.dataset.copyText || btn.textContent, function () {
var old = btn.textContent;
@@ -141,7 +152,8 @@
function bindThumbUploads(container, msgEl) {
container.querySelectorAll(".cf24-opt-value, .cf24-u-row").forEach(function (row) {
var fileEl = row.querySelector('input[type="file"]');
if (!fileEl) return;
if (!fileEl || fileEl.dataset.bound) return;
fileEl.dataset.bound = "1";
fileEl.addEventListener("change", function () {
var f = fileEl.files && fileEl.files[0];
if (!f) return;
@@ -164,6 +176,7 @@
}
function amountPlain(v) { return String(parseInt(String(v || "0").replace(/,/g, ""), 10) || 0); }
function norm(s) { return String(s || "").replace(/\s+/g, "").toLowerCase(); }
/* ═══════════════════════════════════════════════════════════
화면 분기
@@ -179,11 +192,11 @@
/* ═══════════════════════════════════════════════════════════
통합 목록 — 옵션값 1 : 품목 1
═══════════════════════════════════════════════════════════ */
// 행 순서는 **품목 목록 순서**(카페24가 진열순서대로 돌려준다 — 쇼핑몰에 보이는 순서)를
// 따른다. options 응답의 옵션값 순서는 등록 순서라 실제와 다를 수 있다(실물 확인).
// index 는 options 응답 안 위치 — 옵션 PUT 은 이 위치로 짝을 맞춰야 하므로 유지한다.
// 행 = 품목 목록 순서. index 는 options 응답 안 위치(옵션 PUT 짝맞춤용).
// 품목이 없는 옵션값은 state.orphans 로 따로 모은다(행으로 그리지 않는다).
function unifiedRows() {
var opt = state.option;
state.orphans = [];
if (!opt || !opt.has_option) return [];
var group = opt.options[0];
var byText = {};
@@ -192,25 +205,26 @@
state.variants.forEach(function (variant) {
if (!variant.options.length) return;
var r = byText[variant.options[0].value];
if (!r || r.variant) return; // 옵션값이 없거나 이미 배정됨
if (!r || r.variant) return;
r.variant = variant;
rows.push(r);
});
group.option_value.forEach(function (v) {
var r = byText[v.option_text];
if (rows.indexOf(r) < 0) rows.push(r); // 품목이 안 잡힌 옵션값은 뒤에
if (rows.indexOf(r) < 0) state.orphans.push(r);
});
return rows;
}
function rowHtml(r, isNew, canDrag, position) {
function rowHtml(r, canDrag, position) {
var v = r.value || { option_text: "", option_image_file: "", option_link_image: "", option_color: "" };
var variant = r.variant;
var isNewRow = r.index == null;
var thumbSrc = v.option_image_file || v.option_link_image ||
(state.option && state.option.has_option ? variantImageFor(state.option.options[0].option_name, v.option_text) : "");
var code = variant ? variant.variant_code : "";
// data-index: options 응답 안 위치(옵션 PUT 짝맞춤용) / data-pos: 처음 그린 순서(순서 변경 판정용)
return '<tr class="cf24-u-row" data-index="' + (r.index == null ? "" : r.index) + '" data-pos="' + (position == null ? "" : position) + '" data-code="' + esc(code) + '">' +
// data-index: options 응답 안 위치(옵션 PUT 짝맞춤용, 새 행은 빈 값) / data-pos: 처음 그린 순서(순서 변경 판정용)
return '<tr class="cf24-u-row' + (isNewRow ? " is-new" : "") + '" data-index="' + (isNewRow ? "" : r.index) + '" data-pos="' + (position == null ? "" : position) + '" data-code="' + esc(code) + '">' +
'<td class="cf24-u-drag">' + (canDrag ? '<span class="cf24-drag-handle" draggable="true" title="드래그하여 순서 변경">⋮⋮</span>' : "") + "</td>" +
'<td class="cf24-u-thumb"><span class="cf24-opt-thumb-slot">' + thumbHtml(thumbSrc, v.option_image_file ? "옵션 버튼 이미지" : v.option_link_image ? "옵션 연결 이미지" : "품목 이미지") + "</span>" +
'<input type="hidden" name="option_image_file" value="' + esc(v.option_image_file || "") + '" />' +
@@ -219,12 +233,12 @@
'<td class="cf24-u-name"><input type="text" name="option_text" value="' + esc(v.option_text) + '" placeholder="옵션값 이름" /></td>' +
'<td class="cf24-v-syscode">' + (code
? '<button type="button" class="cf24-v-copy" data-copy-text="' + esc(code) + '" title="클릭하면 품목코드를 복사합니다">' + esc(code) + "</button>"
: '<span class="cf24-muted">' + (isNew ? "저장 시 부여" : "—") + "</span>") + "</td>" +
: '<span class="cf24-muted">저장 시 부여</span>') + "</td>" +
'<td class="cf24-v-code"><input type="text" name="custom_variant_code" maxlength="40" value="' + esc(variant ? variant.custom_variant_code : "") + '" /></td>' +
'<td class="cf24-v-amount"><input type="text" name="additional_amount" inputmode="numeric" value="' + esc(amountPlain(variant ? variant.additional_amount : "0")) + '" /></td>' +
'<td class="cf24-v-flag">' + toggleBtn("display", variant ? !!variant.display : true) + "</td>" +
'<td class="cf24-v-flag">' + toggleBtn("selling", variant ? !!variant.selling : true) + "</td>" +
'<td class="cf24-u-del">' + (isNew ? '<button type="button" class="cf24-u-remove" title="행 삭제">✕</button>' : "") + "</td>" +
'<td class="cf24-u-del"><button type="button" class="cf24-u-remove" title="' + (isNewRow ? "행 삭제" : "품목 삭제 (카페24에 바로 반영)") + '">✕</button></td>' +
"</tr>";
}
@@ -241,25 +255,33 @@
' <label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect(group.option_display_type || DEFAULT_TYPE, "option_display_type") + "</label>" +
' <span class="cf24-u-head-actions">' +
' <button type="button" class="erp-btn erp-btn-outline" id="cf24-u-import">옵션값 불러오기</button>' +
(isNew ? ' <button type="button" class="erp-btn erp-btn-outline" id="cf24-u-add">+ 행 추가</button>' : "") +
' <button type="button" class="erp-btn erp-btn-outline" id="cf24-u-add">+ 행 추가</button>' +
" </span>" +
"</div>" +
'<div class="cf24-u-tablewrap"><table class="cf24-variants cf24-u-table"><thead><tr>' +
'<th class="cf24-u-drag"></th><th class="cf24-u-thumb">썸네일</th><th>옵션값 이름</th><th class="cf24-v-syscode">품목코드</th>' +
'<th class="cf24-v-code">자체 품목코드</th><th class="cf24-v-amount">추가금액</th><th class="cf24-v-flag">진열</th><th class="cf24-v-flag">판매</th><th class="cf24-u-del"></th>' +
"</tr></thead><tbody id=\"cf24-u-body\">";
rows.forEach(function (r, i) { html += rowHtml(r, false, canDrag, i); });
if (isNew) html += rowHtml({ index: null, value: null, variant: null }, true, true);
html += "</tbody></table></div>" +
'<p class="cf24-side-note cf24-u-note">' +
rows.forEach(function (r, i) { html += rowHtml(r, canDrag, i); });
if (isNew) html += rowHtml({ index: null, value: null, variant: null }, true, null);
html += "</tbody></table></div>";
if (!isNew && state.orphans.length) {
html += '<p class="cf24-side-note cf24-u-orphans"><strong>품목이 없는 옵션값 ' + state.orphans.length + '개</strong>: ' +
esc(state.orphans.map(function (r) { return r.value.option_text; }).join(", ")) +
' — 쇼핑몰에서 살 수 없는 값입니다. 카페24 API 로는 지울 수 없어 「옵션 재생성」으로 정리합니다(위 행만 다시 만들어집니다).</p>';
}
html += '<p class="cf24-side-note cf24-u-note">' +
(isNew
? "저장하면 옵션(조합형)을 만들고 카페24가 품목을 자동 생성합니다. 부여된 품목코드를 받아 자체 품목코드·추가금액·썸네일까지 함께 반영합니다."
: "옵션값 추가·삭제는 카페24 API 가 지원하지 않습니다(옵션 전체 삭제 후 다시 만들어야 합니다). " +
: "「+ 행 추가」한 값은 저장 시 옵션값 끝에 덧붙여 품목을 만듭니다. ✕ 는 품목을 바로 삭제합니다. " +
(canDrag ? "행을 드래그하면 품목 진열순서로 저장됩니다. " : "") +
"썸네일은 표시방식이 「미리보기(이미지)」일 때 쇼핑몰에 보입니다.") +
"</p>" +
'<div class="cf24-side-actions cf24-u-actions"><span class="cf24-muted" id="cf24-u-msg"></span>' +
' <span>' + (isNew ? "" : '<button type="button" class="erp-btn erp-btn-outline cf24-btn-danger" id="cf24-opt-delete">옵션 전체 삭제</button> ') +
' <span>' + (isNew ? "" :
'<button type="button" class="erp-btn erp-btn-outline cf24-btn-danger" id="cf24-opt-delete">옵션 전체 삭제</button> ' +
'<button type="button" class="erp-btn erp-btn-outline" id="cf24-u-rebuild" title="옵션을 지우고 현재 행으로 다시 만듭니다. 품목코드가 새로 부여됩니다.">옵션 재생성</button> ') +
' <button type="button" class="erp-btn erp-btn-primary" id="cf24-u-save">저장</button></span></div>' +
"</div>";
body.innerHTML = html;
@@ -272,51 +294,139 @@
bindCopy(tbody);
bindThumbUploads(tbody, msg);
if (canDrag) bindDrag(tbody);
if (isNew) {
tbody.querySelectorAll("tr").forEach(function (tr) { bindRow(tr, tbody, msg); });
body.querySelector("#cf24-u-add").addEventListener("click", function () {
tbody.insertAdjacentHTML("beforeend", rowHtml({ index: null, value: null, variant: null }, true, true));
tbody.insertAdjacentHTML("beforeend", rowHtml({ index: null, value: null, variant: null }, canDrag, null));
var tr = tbody.lastElementChild;
bindRowNew(tr, tbody, msg);
bindRow(tr, tbody, msg);
if (canDrag) bindDragRow(tr, tbody);
tr.querySelector('[name="option_text"]').focus();
});
tbody.querySelectorAll("tr").forEach(function (tr) { bindRowNew(tr, tbody, msg); });
}
body.querySelector("#cf24-u-import").addEventListener("click", function () { openImport(isNew, tbody, msg); });
body.querySelector("#cf24-u-save").addEventListener("click", function () { saveUnified(isNew, tbody, msg); });
var del = body.querySelector("#cf24-opt-delete");
if (del) del.addEventListener("click", deleteOptions);
var rebuild = body.querySelector("#cf24-u-rebuild");
if (rebuild) rebuild.addEventListener("click", function () { rebuildFlow(tbody, msg); });
}
function bindRowNew(tr, tbody, msg) {
// 행 하나 연결 — ✕(새 행은 화면에서만 제거, 기존 행은 품목 삭제), 변경 표시, 토글, 썸네일
function bindRow(tr, tbody, msg) {
var rm = tr.querySelector(".cf24-u-remove");
if (rm && !rm.dataset.bound) {
rm.dataset.bound = "1";
rm.addEventListener("click", function () {
if (tbody.children.length <= 1) { tr.querySelector('[name="option_text"]').value = ""; return; }
if (tr.classList.contains("is-new")) {
if (tbody.children.length <= 1 && !state.option.has_option) {
tr.querySelector('[name="option_text"]').value = "";
return;
}
tr.remove();
return;
}
deleteVariantRow(tr, msg);
});
}
trackDirty(tr);
bindToggles(tr);
bindCopy(tr);
bindThumbUploads(tr, msg);
}
function deleteVariantRow(tr, msg) {
var code = tr.dataset.code;
var name = tr.querySelector('[name="option_text"]').value.trim();
if (!code) { tr.remove(); return; }
window.erpConfirm("품목 「" + name + "」(" + code + ")을 카페24에서 삭제합니다. 되돌릴 수 없습니다.\n(옵션값 이름은 남을 수 있으며 「옵션 재생성」으로 정리합니다.) 계속할까요?")
.then(function (ok) {
if (!ok) return;
setMsg(msg, "품목 삭제 중…", "");
apiJson("DELETE", "/cafe24/products/" + no + "/variants/" + encodeURIComponent(code))
.then(function () {
state.variants = state.variants.filter(function (v) { return v.variant_code !== code; });
tr.remove();
// 옵션값은 남으므로 '품목 없는 옵션값' 안내를 갱신하려면 다시 그린다.
var keep = snapshotDom();
render();
restoreDom(keep);
setMsg(body.querySelector("#cf24-u-msg"), "품목 " + code + " 을 삭제했습니다.", "is-ok");
if (count) count.textContent = "(" + state.variants.length + "품목)";
})
.catch(function (err) { setMsg(msg, "삭제 실패: " + err.message, "is-err"); });
});
}
// 다시 그리기 전 편집 중인 값(이름·코드·금액·토글·새 행)을 보관했다가 복원한다.
function snapshotDom() {
var tbody = body.querySelector("#cf24-u-body");
if (!tbody) return null;
return Array.prototype.map.call(tbody.children, function (tr) {
return {
code: tr.dataset.code, isNew: tr.classList.contains("is-new"),
text: tr.querySelector('[name="option_text"]').value,
image: tr.querySelector('[name="option_image_file"]').value,
link: tr.querySelector('[name="option_link_image"]').value,
custom: tr.querySelector('[name="custom_variant_code"]').value,
amount: tr.querySelector('[name="additional_amount"]').value,
display: tr.querySelector('.cf24-v-toggle[data-flag="display"]').dataset.on,
selling: tr.querySelector('.cf24-v-toggle[data-flag="selling"]').dataset.on
};
});
}
function restoreDom(keep) {
if (!keep) return;
var tbody = body.querySelector("#cf24-u-body");
var msg = body.querySelector("#cf24-u-msg");
if (!tbody) return;
var byCode = {};
Array.prototype.forEach.call(tbody.children, function (tr) { if (tr.dataset.code) byCode[tr.dataset.code] = tr; });
keep.forEach(function (k) {
var tr = k.isNew ? null : byCode[k.code];
if (k.isNew) {
tbody.insertAdjacentHTML("beforeend", rowHtml({ index: null, value: null, variant: null }, true, null));
tr = tbody.lastElementChild;
bindRow(tr, tbody, msg);
bindDragRow(tr, tbody);
}
if (!tr) return;
var set = function (sel, val) {
var el = tr.querySelector(sel);
if (el && el.value !== val) { el.value = val; el.dispatchEvent(new Event("input")); }
};
set('[name="option_text"]', k.text);
set('[name="custom_variant_code"]', k.custom);
set('[name="additional_amount"]', k.amount);
if (k.image) { tr.querySelector('[name="option_image_file"]').value = k.image; }
if (k.link) { tr.querySelector('[name="option_link_image"]').value = k.link; }
["display", "selling"].forEach(function (flag) {
var btn = tr.querySelector('.cf24-v-toggle[data-flag="' + flag + '"]');
if (btn && btn.dataset.on !== k[flag]) paintToggle(btn, k[flag] === "1", false);
});
});
}
/* ── 드래그 정렬 (HTML5 DnD, 핸들에서만 시작) ── */
function bindDrag(tbody) {
var dragging = null;
tbody.querySelectorAll(".cf24-drag-handle").forEach(function (h) {
function bindDragRow(tr, tbody) {
var h = tr.querySelector(".cf24-drag-handle");
if (!h || h.dataset.bound) return;
h.dataset.bound = "1";
h.addEventListener("dragstart", function (e) {
dragging = h.closest("tr");
dragging.classList.add("is-dragging");
dragging = tr;
tr.classList.add("is-dragging");
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", dragging.dataset.code || "row"); } catch (err) { /* IE */ }
try { e.dataTransfer.setData("text/plain", tr.dataset.code || "row"); } catch (err) { /* IE */ }
});
h.addEventListener("dragend", function () {
if (dragging) dragging.classList.remove("is-dragging");
dragging = null;
markOrder(tbody);
});
});
}
function bindDrag(tbody) {
tbody.querySelectorAll("tr").forEach(function (tr) { bindDragRow(tr, tbody); });
tbody.addEventListener("dragover", function (e) {
if (!dragging) return;
e.preventDefault();
@@ -376,7 +486,8 @@
'<div class="cf24-modal-head"><h3 class="cf24-modal-title">옵션값 불러오기</h3>' +
'<button type="button" class="cf24-modal-close" aria-label="닫기">✕</button></div>' +
'<div class="cf24-modal-body">' +
'<p class="cf24-side-note">한 줄에 하나씩, 탭(엑셀 복사) 또는 두 칸 이상 공백으로 구분: <code>이름 · 판매가 · 추가금액 · 자체 품목코드</code>. 판매가는 무시합니다. 추가금액 「-」는 0.</p>' +
'<p class="cf24-side-note">한 줄에 하나씩, 탭(엑셀 복사) 또는 두 칸 이상 공백으로 구분: <code>이름 · 판매가 · 추가금액 · 자체 품목코드</code>. 판매가는 무시합니다. 추가금액 「-」는 0.' +
(isNew ? "" : " 이름이 같은 행은 자체코드·추가금액을 채우고, 없는 이름은 새 행으로 추가합니다.") + "</p>" +
'<textarea class="cf24-import-text" placeholder="미라클통 퓨어 스타터 SET\t9,900\t-\tPE-0002\n미라클통 퓨어 스몰 SET\t29,900\t20,000\tPE-0003"></textarea>' +
'<p class="cf24-muted cf24-import-preview"></p>' +
'<div class="cf24-side-actions"><span class="cf24-muted"></span><span>' +
@@ -414,7 +525,14 @@
if (item.amount != null && amtEl) { amtEl.value = String(item.amount); amtEl.dispatchEvent(new Event("input")); }
}
function norm(s) { return String(s || "").replace(/\s+/g, "").toLowerCase(); }
function addNewRow(tbody, msg) {
var canDrag = tbody.querySelector(".cf24-drag-handle") !== null || !state.option || !state.option.has_option;
tbody.insertAdjacentHTML("beforeend", rowHtml({ index: null, value: null, variant: null }, canDrag, null));
var tr = tbody.lastElementChild;
bindRow(tr, tbody, msg);
if (canDrag) bindDragRow(tr, tbody);
return tr;
}
function applyImport(parsed, isNew, tbody, msg) {
if (isNew) {
@@ -423,29 +541,25 @@
return !tr.querySelector('[name="option_text"]').value.trim();
});
if (blank) tbody.innerHTML = "";
parsed.forEach(function (item) {
tbody.insertAdjacentHTML("beforeend", rowHtml({ index: null, value: null, variant: null }, true, true));
var tr = tbody.lastElementChild;
bindRowNew(tr, tbody, msg);
setRowValues(tr, item);
});
parsed.forEach(function (item) { setRowValues(addNewRow(tbody, msg), item); });
setMsg(msg, parsed.length + "행을 채웠습니다. 「저장」을 누르면 카페24에 반영됩니다.", "is-ok");
return;
}
var rows = Array.prototype.slice.call(tbody.children);
var matched = 0, missing = [];
var matched = 0, added = 0, orphan = [];
parsed.forEach(function (item) {
var tr = null;
for (var i = 0; i < rows.length; i++) {
if (norm(rows[i].querySelector('[name="option_text"]').value) === norm(item.name)) { tr = rows[i]; break; }
}
if (!tr) { missing.push(item.name); return; }
setRowValues(tr, { code: item.code, amount: item.amount });
matched++;
if (tr) { setRowValues(tr, { code: item.code, amount: item.amount }); matched++; return; }
if (state.orphans.some(function (r) { return norm(r.value.option_text) === norm(item.name); })) { orphan.push(item.name); return; }
setRowValues(addNewRow(tbody, msg), item);
added++;
});
var text = matched + "행에 자체코드·추가금액을 채웠습니다.";
if (missing.length) text += " 일치하는 옵션값이 없어 건너뜀: " + missing.join(", ") + " (옵션값 추가는 API 미지원)";
setMsg(msg, text, missing.length ? "is-err" : "is-ok");
var text = matched + "행을 채우고 " + added + "행을 새로 추가했습니다. 「저장」을 누르면 카페24에 반영됩니다.";
if (orphan.length) text += " 품목 없는 옵션값과 이름이 같아 건너뜀(옵션 재생성으로 정리): " + orphan.join(", ");
setMsg(msg, text, orphan.length ? "is-err" : "is-ok");
}
/* ── 저장 (통합) ── */
@@ -454,6 +568,7 @@
return {
tr: tr,
position: i,
isNew: tr.classList.contains("is-new"),
index: tr.dataset.index === "" ? null : parseInt(tr.dataset.index, 10),
code: tr.dataset.code || "",
text: tr.querySelector('[name="option_text"]').value.trim(),
@@ -471,8 +586,11 @@
var seen = {};
for (var i = 0; i < rows.length; i++) {
if (!rows[i].text) return "옵션값 이름이 비어 있는 행이 있습니다.";
if (seen[rows[i].text]) return "옵션값 이름이 중복됩니다: " + rows[i].text;
seen[rows[i].text] = true;
if (seen[norm(rows[i].text)]) return "옵션값 이름이 중복됩니다: " + rows[i].text;
seen[norm(rows[i].text)] = true;
if (rows[i].isNew && state.orphans.some(function (r) { return norm(r.value.option_text) === norm(rows[i].text); })) {
return "「" + rows[i].text + "」은 품목 없는 옵션값으로 이미 있습니다. 「옵션 재생성」으로 정리한 뒤 추가하세요.";
}
}
return "";
}
@@ -488,6 +606,23 @@
return any ? patch : null;
}
// 새 행(아직 품목 없음)의 초기 패치 — 기본값(빈 코드·0원·진열·판매)과 다른 것만
function newRowPatch(r, code) {
var p = { variant_code: code };
var any = false;
if (r.custom.value.trim()) { p.custom_variant_code = r.custom.value.trim(); any = true; }
if (amountPlain(r.amount.value) !== "0") { p.additional_amount = r.amount.value.trim(); any = true; }
if (r.display.dataset.on !== "1") { p.display = "off"; any = true; }
if (r.selling.dataset.on !== "1") { p.selling = "off"; any = true; }
return any ? p : null;
}
function codesByValue(variants) {
var map = {};
(variants || []).forEach(function (v) { if (v.options.length) map[norm(v.options[0].value)] = v.variant_code; });
return map;
}
function saveUnified(isNew, tbody, msg) {
var rows = collectUnified(tbody);
var problem = validateNames(rows);
@@ -499,11 +634,11 @@
if (isNew) {
window.erpConfirm("옵션 「" + optName + "」과 옵션값 " + rows.length + "개를 만들고 품목을 생성합니다.\n카페24에 바로 반영됩니다. 계속할까요?")
.then(function (ok) { if (ok) createFlow(); });
.then(function (ok) { if (ok) createFromRows(rows, optName, optType, saveBtn, msg, false); });
return;
}
// 기존 옵션 — 옵션(PUT) 은 **원래 순서**로 짝을 맞춰 보낸다(재배열은 display_order 로).
// 기존 옵션 — 옵션(PUT) 은 **원래 순서**로 짝을 맞추고, 새 행은 끝에 덧붙인다.
var group = state.option.options[0];
var optionsChanged = optName !== group.option_name || optType !== (group.option_display_type || DEFAULT_TYPE);
var edited = group.option_value.map(function (ov, vi) {
@@ -513,6 +648,11 @@
if (r.text !== ov.option_text || r.image !== (ov.option_image_file || "") || r.link !== (ov.option_link_image || "")) optionsChanged = true;
return { option_text: r.text, option_image_file: r.image, option_link_image: r.link, option_color: ov.option_color || "" };
});
var newRows = rows.filter(function (r) { return r.isNew; });
newRows.forEach(function (r) {
edited.push({ option_text: r.text, option_image_file: r.image, option_link_image: r.link, option_color: "" });
optionsChanged = true;
});
var orderChanged = tbody.classList.contains("is-order-dirty");
var patches = [];
rows.forEach(function (r) {
@@ -523,7 +663,7 @@
if (!optionsChanged && !patches.length) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
var lines = [];
if (optionsChanged) lines.push("옵션명·옵션값 이름/썸네일/표시방식");
if (optionsChanged) lines.push("옵션명·옵션값 이름/썸네일/표시방식" + (newRows.length ? " (+ 옵션값 " + newRows.length + "개 추가 → 품목 생성)" : ""));
if (patches.length) lines.push("품목 " + patches.length + "건 (자체코드·추가금액·진열/판매" + (orderChanged ? "·순서" : "") + ")");
window.erpConfirm("카페24에 바로 반영합니다. 계속할까요?\n\n- " + lines.join("\n- ")).then(function (ok) {
if (!ok) return;
@@ -538,85 +678,73 @@
}).then(function (data) {
state.option = data.option;
if (data.variants) state.variants = data.variants;
if (newRows.length) {
// 새 품목코드를 이름으로 짝지어 자체코드·추가금액·진열/판매 패치를 만든다
var map = codesByValue(state.variants);
var missing = 0;
newRows.forEach(function (r) {
var code = map[norm(r.text)];
if (!code) { missing++; return; }
var p = newRowPatch(r, code);
if (p) patches.push(p);
});
if (missing) setMsg(msg, "옵션값은 추가됐지만 품목 " + missing + "건은 카페24 품목 조회가 늦어 자체코드/추가금액을 넣지 못했습니다 — 저장 후 다시 시도하세요.", "is-err");
}
});
});
}
if (patches.length) {
chain = chain.then(function () {
return apiJson("PUT", "/cafe24/products/" + no + "/variants", { rows: patches }).then(function (data) {
var updated = data.updated || {};
state.variants.forEach(function (v) {
var u = updated[v.variant_code];
if (!u) return;
if (u.custom_variant_code !== undefined) v.custom_variant_code = u.custom_variant_code;
if (u.additional_amount !== undefined) v.additional_amount = u.additional_amount;
if (u.display !== undefined) v.display = !!u.display;
if (u.selling !== undefined) v.selling = !!u.selling;
if (!patches.length) return null;
setMsg(msg, "품목 " + patches.length + "건 반영 중…", "");
return apiJson("PUT", "/cafe24/products/" + no + "/variants", { rows: patches });
});
if (orderChanged) {
// 화면 순서를 상태에도 반영(다시 그려도 지금 순서 유지)
var byCode = {};
state.variants.forEach(function (v) { byCode[v.variant_code] = v; });
var reordered = [];
rows.forEach(function (r) { if (byCode[r.code]) reordered.push(byCode[r.code]); });
state.variants.forEach(function (v) { if (reordered.indexOf(v) < 0) reordered.push(v); });
state.variants = reordered;
var g = state.option.options[0];
var vals = [];
rows.forEach(function (r) { if (r.index !== null && g.option_value[r.index]) vals.push(g.option_value[r.index]); });
g.option_value.forEach(function (ov) { if (vals.indexOf(ov) < 0) vals.push(ov); });
g.option_value = vals;
}
});
});
}
chain
.then(function () { return load(); })
.then(function () {
render();
setMsg(body.querySelector("#cf24-u-msg"), "카페24에 반영했습니다.", "is-ok");
if (count) count.textContent = "(" + state.variants.length + "품목)";
})
.catch(function (err) {
setMsg(msg, "실패: " + err.message + " — 화면을 다시 불러옵니다.", "is-err");
setTimeout(load, 1500);
setTimeout(load, 2000);
})
.then(function () { saveBtn.disabled = false; });
});
}
function createFlow() {
// 옵션 없는 상태에서 행들로 옵션을 만든다(재생성 시에는 먼저 DELETE 를 거친다).
function createFromRows(rows, optName, optType, saveBtn, msg, isRebuild) {
saveBtn.disabled = true;
var chain = Promise.resolve();
if (isRebuild) {
setMsg(msg, "기존 옵션·품목 삭제 중…", "");
chain = chain.then(function () { return apiJson("DELETE", "/cafe24/products/" + no + "/options"); });
}
chain
.then(function () {
setMsg(msg, "옵션 생성 중… (카페24가 품목을 만들고 코드를 부여합니다)", "");
var names = rows.map(function (r) { return r.text; });
apiJson("POST", "/cafe24/products/" + no + "/options", {
option_name: optName, values: names, display_type: optType
return apiJson("POST", "/cafe24/products/" + no + "/options", {
option_name: optName, values: rows.map(function (r) { return r.text; }), display_type: optType
});
})
.then(function (data) {
state.option = data.option;
state.variants = data.variants || [];
// 부여된 품목코드를 옵션값 이름으로 짝지어 자체코드·추가금액·진열/판매를 보낸다
var byValue = {};
state.variants.forEach(function (v) { if (v.options.length) byValue[v.options[0].value] = v.variant_code; });
var map = codesByValue(state.variants);
var patches = [];
var unmatched = 0;
rows.forEach(function (r) {
var code = byValue[r.text];
var code = map[norm(r.text)];
if (!code) { unmatched++; return; }
var p = { variant_code: code };
var any = false;
if (r.custom.value.trim()) { p.custom_variant_code = r.custom.value.trim(); any = true; }
if (amountPlain(r.amount.value) !== "0") { p.additional_amount = r.amount.value.trim(); any = true; }
if (r.display.dataset.on !== "1") { p.display = "off"; any = true; }
if (r.selling.dataset.on !== "1") { p.selling = "off"; any = true; }
if (any) patches.push(p);
var p = newRowPatch(r, code);
if (p) patches.push(p);
});
var chain = Promise.resolve();
var inner = Promise.resolve();
if (patches.length) {
setMsg(msg, "품목 " + patches.length + "건에 자체코드·추가금액 반영 중…", "");
chain = chain.then(function () { return apiJson("PUT", "/cafe24/products/" + no + "/variants", { rows: patches }); });
inner = inner.then(function () { return apiJson("PUT", "/cafe24/products/" + no + "/variants", { rows: patches }); });
}
var images = rows.filter(function (r) { return r.image || r.link; });
if (images.length) {
chain = chain.then(function () {
if (rows.some(function (r) { return r.image || r.link; })) {
inner = inner.then(function () {
setMsg(msg, "썸네일 반영 중…", "");
var edited = state.option.options[0].option_value.map(function (ov) {
var r = null;
@@ -629,21 +757,35 @@
});
});
}
return chain.then(function () { return unmatched; });
return inner.then(function () { return unmatched; });
})
.then(function (unmatched) {
return load().then(function () {
var m = body.querySelector("#cf24-u-msg");
setMsg(m, unmatched
? "옵션을 만들었습니다. 품목 " + unmatched + "건은 카페24 품목 조회가 늦어 자체코드/추가금액을 넣지 못했습니다 — 다시 저장하세요."
: "옵션·품목을 만들고 카페24에 반영했습니다.", unmatched ? "is-err" : "is-ok");
: (isRebuild ? "옵션·품목을 다시 만들고 카페24에 반영했습니다. 품목코드가 새로 부여되었습니다." : "옵션·품목을 만들고 카페24에 반영했습니다."),
unmatched ? "is-err" : "is-ok");
});
})
.catch(function (err) {
setMsg(msg, "실패: " + err.message, "is-err");
setMsg(msg, "실패: " + err.message + (isRebuild ? " — 옵션 상태를 확인하려면 창을 닫았다 다시 여세요." : ""), "is-err");
saveBtn.disabled = false;
});
}
// 옵션 재생성 — 전체 삭제 후 현재 행(이름·코드·금액·진열/판매·썸네일)으로 다시 만든다.
function rebuildFlow(tbody, msg) {
var rows = collectUnified(tbody);
var problem = validateNames(rows.map(function (r) { return { text: r.text, isNew: false }; }));
if (problem) { window.erpAlert(problem); return; }
var optName = body.querySelector('[name="option_name"]').value.trim();
var optType = body.querySelector('[name="option_display_type"]').value;
if (!optName) { window.erpAlert("옵션명을 입력하세요."); return; }
var saveBtn = body.querySelector("#cf24-u-save");
var orphanNote = state.orphans.length ? "\n- 품목 없는 옵션값 " + state.orphans.length + "개는 사라집니다." : "";
window.erpConfirm("옵션을 전부 지우고 현재 " + rows.length + "행으로 다시 만듭니다.\n\n- 모든 품목코드가 새로 부여됩니다(주문 이력·외부 연동이 품목코드를 참조하면 영향).\n- 재고 수량은 초기화될 수 있습니다." + orphanNote + "\n\n되돌릴 수 없습니다. 계속할까요?")
.then(function (ok) { if (ok) createFromRows(rows, optName, optType, saveBtn, msg, true); });
}
/* ═══════════════════════════════════════════════════════════
@@ -676,7 +818,7 @@
html += '<p class="cf24-muted">품목이 아직 조회되지 않았습니다. 잠시 뒤 창을 닫았다 다시 여세요.</p>';
} else {
html += '<div class="cf24-variants-wrap"><table class="cf24-variants"><thead><tr><th class="cf24-v-imgcol"></th><th>옵션</th><th class="cf24-v-syscode">품목코드</th>' +
'<th class="cf24-v-code">자체 품목코드</th><th class="cf24-v-amount">추가금액</th><th class="cf24-v-flag">진열</th><th class="cf24-v-flag">판매</th></tr></thead><tbody>';
'<th class="cf24-v-code">자체 품목코드</th><th class="cf24-v-amount">추가금액</th><th class="cf24-v-flag">진열</th><th class="cf24-v-flag">판매</th><th class="cf24-u-del"></th></tr></thead><tbody>';
variants.forEach(function (v) {
var label = v.options.map(function (o) { return o.value; }).join(" / ") || v.variant_code;
html += '<tr class="cf24-v-item" data-code="' + esc(v.variant_code) + '">' +
@@ -685,7 +827,8 @@
'<td class="cf24-v-syscode"><button type="button" class="cf24-v-copy" data-copy-text="' + esc(v.variant_code) + '">' + esc(v.variant_code) + "</button></td>" +
'<td class="cf24-v-code"><input type="text" name="custom_variant_code" maxlength="40" value="' + esc(v.custom_variant_code) + '" /></td>' +
'<td class="cf24-v-amount"><input type="text" name="additional_amount" inputmode="numeric" value="' + esc(amountPlain(v.additional_amount)) + '" /></td>' +
'<td class="cf24-v-flag">' + toggleBtn("display", !!v.display) + "</td><td class=\"cf24-v-flag\">" + toggleBtn("selling", !!v.selling) + "</td></tr>";
'<td class="cf24-v-flag">' + toggleBtn("display", !!v.display) + "</td><td class=\"cf24-v-flag\">" + toggleBtn("selling", !!v.selling) + "</td>" +
'<td class="cf24-u-del"><button type="button" class="cf24-u-remove" data-code="' + esc(v.variant_code) + '" title="품목 삭제 (카페24에 바로 반영)">✕</button></td></tr>';
});
html += "</tbody></table></div><div class=\"cf24-side-actions\" style=\"margin-top:8px;\"><span class=\"cf24-muted\" id=\"cf24-var-msg\"></span>" +
'<button type="button" class="erp-btn erp-btn-primary" id="cf24-var-save">품목 저장</button></div>';
@@ -704,6 +847,25 @@
bindToggles(wrap);
bindCopy(wrap);
body.querySelector("#cf24-var-save").addEventListener("click", function () { saveMultiVariants(wrap); });
wrap.querySelectorAll(".cf24-u-remove").forEach(function (btn) {
btn.addEventListener("click", function () {
var tr = btn.closest("tr");
var msg = body.querySelector("#cf24-var-msg");
window.erpConfirm("품목 「" + tr.querySelector(".cf24-v-name").textContent + "」(" + btn.dataset.code + ")을 카페24에서 삭제합니다. 되돌릴 수 없습니다. 계속할까요?")
.then(function (ok) {
if (!ok) return;
setMsg(msg, "품목 삭제 중…", "");
apiJson("DELETE", "/cafe24/products/" + no + "/variants/" + encodeURIComponent(btn.dataset.code))
.then(function () {
state.variants = state.variants.filter(function (v) { return v.variant_code !== btn.dataset.code; });
tr.remove();
setMsg(msg, "품목 " + btn.dataset.code + " 을 삭제했습니다.", "is-ok");
if (count) count.textContent = "(" + state.variants.length + "품목)";
})
.catch(function (err) { setMsg(msg, "삭제 실패: " + err.message, "is-err"); });
});
});
});
}
}
+18 -4
View File
@@ -67,10 +67,11 @@ app/modules/cafe24/ ← 상품관리 모듈
| `POST /cafe24/products/{product_no}/image` | 대표 이미지 교체 (multipart `file`) — 업로드 후 PUT `detail_image` + `image_upload_type=A`. 감사로그 `set_main_image` | `cafe24` |
| `GET /cafe24/products/{product_no}/options` | 옵션 + 품목 조회 (JSON, 읽기 지연 보정 포함) | `cafe24` |
| `POST /cafe24/products/{product_no}/options` | 옵션 생성 (`{option_name, values, display_type}`) — 조합형, 품목 자동 생성 | `cafe24` |
| `PUT /cafe24/products/{product_no}/options` | 옵션명·옵션값 이름/썸네일/표시방식 수정 (`{options:[...], option_list_type}`) | `cafe24` |
| `PUT /cafe24/products/{product_no}/options` | 옵션명·옵션값 이름/썸네일/표시방식 수정 + 옵션값 끝에 덧붙이기 (`{options:[...], option_list_type}` → 덧붙였으면 `added` 와 새 품목 포함) | `cafe24` |
| `DELETE /cafe24/products/{product_no}/options` | 옵션 삭제 — **품목도 함께 삭제**(화면 확인창) | `cafe24` |
| `POST /cafe24/products/{product_no}/options/image` | 옵션값 썸네일 업로드만 (multipart → `{path}`) | `cafe24` |
| `PUT /cafe24/products/{product_no}/variants` | 품목 자체코드·추가금액·진열·판매 일괄 수정 (`{rows:[...]}`, 바뀐 행만) | `cafe24` |
| `PUT /cafe24/products/{product_no}/variants` | 품목 자체코드·추가금액·진열·판매·진열순서 일괄 수정 (`{rows:[...]}`, 바뀐 행만) | `cafe24` |
| `DELETE /cafe24/products/{product_no}/variants/{variant_code}` | 품목 1건 삭제. 감사로그 `delete_variant` | `cafe24` |
| `GET /cafe24/schedules` | 예약 목록 · 취소 | `cafe24` |
| `POST /cafe24/schedules` | 예약 등록 (편집기에서) | `cafe24` |
| `POST /cafe24/schedules/{id}/cancel` | 대기 중 예약 취소 | `cafe24` |
@@ -479,8 +480,21 @@ cafe24_oauth_tokens 저장
돌려주는 순서를 따른다.
- **옵션값 불러오기**: 텍스트(탭 또는 두 칸 이상 공백 구분 — 이름 · 판매가 · 추가금액 ·
자체코드, 판매가는 무시, 추가금액 「-」는 0)를 붙여넣어 행에 채운다. 옵션이 있는 상품은
이름(공백 무시)으로 짝을 찾아 자체코드/추가금액만 채우고, 없는 이름은 건너뛴다(옵션값
추가는 API 미지원). 카페24 관리자에서 "옵션 연결 이미지"로만
이름(공백 무시)으로 짝을 찾아 자체코드/추가금액만 채우고, 없는 이름은 새 행으로 추가한다.
- **품목 추가**(「+ 행 추가」): 저장 시 옵션값을 **끝에 덧붙여** PUT options 한다
(`build_update_options_request(allow_append=True)`). 앞쪽 위치는 그대로라 기존 이름이
밀리지 않는다. 카페24가 받아들이면 품목을 자동 생성하므로 `wait_for_variants` 로 새
코드를 받아 자체코드·추가금액·진열/판매를 이어서 PUT 한다. 거부하면 그 오류가 화면에 뜬다
(문서상 "옵션 항목 추가/삭제 불가" 문구가 있어 실물 확인 필요 — 안 되면 아래 재생성).
- **품목 삭제**(행의 ✕): `DELETE /admin/products/{no}/variants/{code}`(문서에 있는 유일한
제거 수단). 옵션값 이름은 남을 수 있다 → 화면 하단 "품목이 없는 옵션값" 안내. 카페24 GET
이 한동안 삭제된 품목을 계속 돌려주므로 스냅샷에 `{"_deleted": true}` 를 남겨 걸러낸다.
옵션값 **줄이기**(개수 감소)는 서버가 거부한다 — 위치 짝맞춤으로 뒤 값들의 이름이
엉뚱한 품목에 붙는다.
- **옵션 재생성**: 옵션 전체 DELETE → 현재 행으로 POST(옵션값 순서 = 행 순서) → 새 품목코드
받아 자체코드/추가금액/진열/판매 PUT → 썸네일 있으면 옵션 PUT. 품목 없는 옵션값 정리와
"추가가 안 될 때"의 우회 수단. **품목코드가 전부 새로 부여**되고 재고가 초기화될 수 있어
강한 확인창을 거친다. 카페24 관리자에서 "옵션 연결 이미지"로만
등록한 상품은 `option_image_file` 이 비어 있고 품목 `image` 에만 나타난다(실물 —
버튼 이미지만 보면 "없음"으로 오인했던 사고). 품목은 모달 오른쪽 열에 표(이미지 ·
옵션 · 품목코드 · 자체코드 · 추가금액 · 진열 · 판매)로 그린다. "옵션별 상품코드/추가금액/진열" 은 품목