feat(cafe24): 옵션값+품목 통합 목록, 드래그 순서, 옵션값 불러오기

- 옵션 1개 상품은 한 행에 썸네일·옵션값 이름·품목코드(복사)·자체코드·
  추가금액·진열·판매를 두고 「저장」 한 번으로 옵션 PUT → 품목 PUT.
- 옵션 없는 상품: 행 추가/삭제 후 저장 → 옵션 생성 → 카페24가 부여한
  품목코드를 재시도 조회(products.wait_for_variants)로 받아 자체코드·
  추가금액·썸네일까지 이어서 반영.
- 순서 드래그는 품목 display_order 로 저장(옵션값 재배열 PUT 은 위치 짝맞춤
  때문에 품목코드↔이름이 뒤바뀔 수 있어 사용하지 않음).
- 「옵션값 불러오기」: 탭/공백 구분 텍스트(이름·판매가·추가금액·자체코드)
  붙여넣기 → 행 자동 채움. 기존 옵션은 이름으로 짝지어 코드/금액만.
- 옵션 2개 이상(조합) 상품은 기존 2열 화면 유지.
- 모달 JS 를 app/static/cafe24-options.js 로 분리. 유닛테스트 90 통과,
  통합 하네스 통과, 헤드리스 크롬 렌더 확인.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:25:52 +09:00
parent be0d44ded6
commit 8125a59366
8 changed files with 1002 additions and 360 deletions
+27
View File
@@ -402,6 +402,33 @@ def list_variants(client: Cafe24Client, product_no: int) -> list[dict[str, Any]]
return variants if isinstance(variants, list) else [] return variants if isinstance(variants, list) else []
def wait_for_variants(
client: Cafe24Client,
product_no: int,
expected: int,
*,
attempts: int = 4,
delay: float = 0.8,
) -> list[dict[str, Any]]:
"""옵션 생성 직후 카페24가 자동 생성한 품목이 조회될 때까지 짧게 재시도한다.
상세설명과 같은 읽기 지연이 품목 조회에도 있다 — POST options 직후 GET variants 가
비어 있거나 일부만 올 수 있다. 기대 개수(옵션값 수)만큼 오면 바로 돌려준다.
끝까지 못 채워도 마지막 결과를 돌려준다(호출부가 안내).
"""
latest: list[dict[str, Any]] = []
for attempt in range(max(1, attempts)):
if attempt:
time.sleep(delay)
try:
latest = list_variants(client, product_no)
except Exception: # noqa: BLE001 — 조회 실패는 다음 시도로
latest = []
if len(latest) >= max(1, expected):
return latest
return latest
def update_variants( def update_variants(
client: Cafe24Client, product_no: int, requests: list[dict[str, Any]] client: Cafe24Client, product_no: int, requests: list[dict[str, Any]]
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
+6 -6
View File
@@ -327,11 +327,11 @@ def options_create(
st.log_audit(actor=actor, action="create_options", product_no=product_no, result="SUCCESS", st.log_audit(actor=actor, action="create_options", product_no=product_no, result="SUCCESS",
detail=f"옵션 '{body['options'][0]['option_name']}' 생성: {', '.join(values)}") detail=f"옵션 '{body['options'][0]['option_name']}' 생성: {', '.join(values)}")
logger.info("카페24 상품 %s 옵션 생성 (%s)", product_no, actor) logger.info("카페24 상품 %s 옵션 생성 (%s)", product_no, actor)
try: # 카페24가 자동 생성한 품목(코드 부여)을 바로 돌려준다 — 읽기 지연이 있어 짧게 재시도.
variants = products.list_variants(api.client, product_no) # 화면은 이 코드로 자체코드·추가금액을 이어서 PUT 한다.
except Cafe24Error as exc: # 옵션은 만들어졌다 — 품목 목록만 비워서 돌려준다. variants = products.wait_for_variants(api.client, product_no, len(values))
logger.warning("카페24 상품 %s 옵션 생성 후 품목 조회 실패: %s", product_no, exc) if len(variants) < len(values):
variants = [] logger.warning("카페24 상품 %s 옵션 생성 후 품목 %s/%s 건만 조회됨", product_no, len(variants), len(values))
return {"ok": True, "option": _options_view(created), "variants": [_variant_view(v) for v in variants]} return {"ok": True, "option": _options_view(created), "variants": [_variant_view(v) for v in variants]}
@@ -484,7 +484,7 @@ def variants_update(
for r in results: for r in results:
code = str(r.get("variant_code") or "") code = str(r.get("variant_code") or "")
if code in by_code: if code in by_code:
for key in ("custom_variant_code", "additional_amount", "display", "selling"): for key in ("custom_variant_code", "additional_amount", "display", "selling", "display_order"):
if key in r and r[key] is not None: if key in r and r[key] is not None:
by_code[code][key] = r[key] by_code[code][key] = r[key]
st.save_write_snapshot(product_no, "variants", by_code) st.save_write_snapshot(product_no, "variants", by_code)
+18 -3
View File
@@ -649,10 +649,16 @@ ADDITIONAL_AMOUNT_MAX = 2_147_483_647
def parse_option_values(raw: object) -> list[str]: def parse_option_values(raw: object) -> list[str]:
"""'빨강, 파랑\\n노랑' → ['빨강','파랑','노랑'] (중복·빈 값 제거, 순서 유지).""" """'빨강, 파랑\\n노랑' 또는 ['빨강','파랑'] → ['빨강','파랑','노랑'] (중복·빈 값 제거, 순서 유지).
text = str(raw or "")
목록으로 오면 항목 안의 쉼표는 이름의 일부로 본다(화면이 행 단위로 보낼 때).
"""
if isinstance(raw, (list, tuple)):
parts = [str(p) for p in raw]
else:
parts = re.split(r"[,\n]", str(raw or ""))
seen: list[str] = [] seen: list[str] = []
for part in re.split(r"[,\n]", text): for part in parts:
value = part.strip() value = part.strip()
if value and value not in seen: if value and value not in seen:
seen.append(value) seen.append(value)
@@ -785,6 +791,15 @@ def build_variant_updates(rows: list[dict]) -> list[dict]:
for flag in ("display", "selling"): for flag in ("display", "selling"):
if row.get(flag) is not None: if row.get(flag) is not None:
item[flag] = "T" if parse_tristate(row[flag]) else "F" item[flag] = "T" if parse_tristate(row[flag]) else "F"
# 진열 순서(1~300) — 카페24 문서: 조합형 옵션 품목에만. 화면의 드래그 정렬이 보낸다.
if row.get("display_order") is not None:
try:
order = int(row["display_order"])
except (TypeError, ValueError):
raise ValueError("진열 순서는 숫자여야 합니다.") from None
if not 1 <= order <= 300:
raise ValueError("진열 순서는 1~300 사이여야 합니다.")
item["display_order"] = order
if len(item) > 1: if len(item) > 1:
out.append(item) out.append(item)
return out return out
+10 -349
View File
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260918d" /> <link rel="stylesheet" href="/static/cafe24.css?v=20260918e" />
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -125,6 +125,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/cafe24-options.js?v=20260918e"></script>
<script> <script>
(function () { (function () {
var pane = document.getElementById("cf24-editor-pane"); var pane = document.getElementById("cf24-editor-pane");
@@ -913,355 +914,15 @@
}); });
})(); })();
// ── 옵션 / 품목 — 「팝업에서 관리」 버튼으로 모달을 열고 그때 불러온다 ── // ── 옵션 / 품목 — 모달 로직은 /static/cafe24-options.js 에 있다(크기 때문에 분리).
(function setupOptions() { // 통합 목록(옵션값+품목 한 행) · 드래그 순서 · 옵션값 불러오기 · 저장 한 번에 처리.
var openBtn = root.querySelector("#cf24-options-open"); if (window.cf24SetupOptions) {
var body = optModal.body; window.cf24SetupOptions({
var count = root.querySelector("#cf24-options-count"); root: root, productNo: no, modal: optModal,
if (!openBtn || !body) return; apiJson: apiJson, escHtml: escHtml, setMsg: setMsg,
var state = { option: null, variants: [], displayTypes: {} }; trackDirty: trackDirty, commitValues: commitValues
openBtn.addEventListener("click", function () {
var nameEl = document.getElementById("cf24-name-text");
optModal.open((nameEl ? nameEl.textContent : "") + " (상품번호 " + no + ")");
load();
}); });
}
function load() {
body.innerHTML = '<p class="cf24-muted">카페24에서 불러오는 중…</p>';
apiJson("GET", "/cafe24/products/" + no + "/options")
.then(function (data) {
state.option = data.option;
state.variants = data.variants || [];
state.displayTypes = data.display_types || {};
render();
})
.catch(function (err) {
body.innerHTML = '<p class="cf24-err">옵션을 불러오지 못했습니다: ' + escHtml(err.message) +
'</p><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-retry">다시 시도</button>';
body.querySelector("#cf24-opt-retry").addEventListener("click", load);
});
}
function typeSelect(current, name) {
var out = '<select name="' + name + '">';
Object.keys(state.displayTypes).forEach(function (k) {
out += '<option value="' + k + '"' + (k === current ? " selected" : "") + ">" + escHtml(state.displayTypes[k]) + "</option>";
});
return out + "</select>";
}
function render() {
var opt = state.option || { has_option: false, options: [] };
var variants = state.variants || [];
if (count) count.textContent = opt.has_option ? "(" + variants.length + "품목)" : "(옵션 없음)";
if (!opt.has_option) {
body.innerHTML =
'<p class="cf24-side-note">이 상품에는 옵션이 없습니다. 옵션명과 옵션값을 넣어 조합형 옵션을 만들면 품목이 자동으로 생성됩니다.</p>' +
'<form class="cf24-side-form cf24-opt-create" id="cf24-opt-create">' +
' <label class="cf24-side-field"><span>옵션명</span><input type="text" name="option_name" placeholder="예: 색상" /></label>' +
' <label class="cf24-side-field"><span>옵션값 (쉼표 또는 줄바꿈으로 구분)</span><textarea name="values" placeholder="예: 빨강, 파랑, 노랑"></textarea></label>' +
' <label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect("S", "display_type") + "</label>" +
' <div class="cf24-side-actions"><span class="cf24-muted" id="cf24-opt-msg"></span>' +
' <button class="erp-btn erp-btn-primary" type="submit">옵션 만들기</button></div>' +
"</form>";
var form = body.querySelector("#cf24-opt-create");
form.addEventListener("submit", function (e) {
e.preventDefault();
var payload = {
option_name: form.querySelector('[name="option_name"]').value.trim(),
values: form.querySelector('[name="values"]').value,
display_type: form.querySelector('[name="display_type"]').value
};
if (!payload.option_name || !payload.values.trim()) {
window.erpAlert("옵션명과 옵션값을 입력하세요.");
return;
}
window.erpConfirm("옵션 「" + payload.option_name + "」을(를) 만들고 품목을 생성합니다.\n카페24에 바로 반영됩니다. 계속할까요?")
.then(function (ok) {
if (!ok) return;
var msg = form.querySelector("#cf24-opt-msg");
setMsg(msg, "생성 중…", "");
apiJson("POST", "/cafe24/products/" + no + "/options", payload)
.then(function (data) { state.option = data.option; state.variants = data.variants || []; render(); })
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); });
});
});
return;
}
// 옵션값 썸네일은 세 곳 중 있는 것을 보여준다 — 옵션 버튼 이미지(option_image_file)
// → 옵션 연결 이미지(option_link_image) → 그 옵션값을 가진 품목의 image.
// 카페24 관리자에서 "옵션 연결 이미지"로만 등록한 상품은 option_image_file 이
// 비어 있고 품목 image 에만 나타난다(실물 — 버튼 이미지만 보면 "없음"으로 오인).
function variantImageFor(name, value) {
for (var i = 0; i < variants.length; i++) {
var v = variants[i];
if (!v.image) continue;
for (var j = 0; j < v.options.length; j++) {
if (v.options[j].name === name && v.options[j].value === value) return v.image;
}
}
return "";
}
// 옵션 정의 편집 (모달 왼쪽 열)
var html = '<div class="cf24-modal-grid"><section class="cf24-modal-col cf24-modal-col-options">' +
'<h4 class="cf24-side-title">옵션 정의</h4>' +
'<form class="cf24-side-form" id="cf24-opt-form">';
opt.options.forEach(function (o, gi) {
html += '<div class="cf24-opt-group" data-group="' + gi + '">' +
'<div class="cf24-opt-head">' +
' <label class="cf24-side-field"><span>옵션명</span><input type="text" name="option_name" value="' + escHtml(o.option_name) + '" /></label>' +
' <label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect(o.option_display_type || "S", "option_display_type") + "</label>" +
"</div>";
o.option_value.forEach(function (v, vi) {
var thumbSrc = v.option_image_file || v.option_link_image || variantImageFor(o.option_name, v.option_text);
var thumb = thumbSrc
? '<img class="cf24-opt-thumb" src="' + escHtml(thumbSrc) + '" alt="" title="' +
(v.option_image_file ? "옵션 버튼 이미지" : v.option_link_image ? "옵션 연결 이미지" : "품목 이미지") + '" />'
: '<div class="cf24-opt-thumb-empty">없음</div>';
html += '<div class="cf24-opt-value" data-value="' + vi + '">' +
'<span class="cf24-opt-thumb-slot">' + thumb + "</span>" +
'<input type="text" name="option_text" value="' + escHtml(v.option_text) + '" placeholder="옵션값 이름" />' +
'<input type="hidden" name="option_image_file" value="' + escHtml(v.option_image_file) + '" />' +
'<input type="hidden" name="option_link_image" value="' + escHtml(v.option_link_image) + '" />' +
'<label class="cf24-file-pick"><input type="file" accept="image/jpeg,image/png,image/gif,image/webp" />' +
'<span class="erp-btn erp-btn-outline">썸네일</span></label>' +
"</div>";
});
html += "</div>";
});
html += '<p class="cf24-side-note">옵션값 추가·삭제는 카페24 API 가 지원하지 않습니다(옵션 삭제 후 다시 만들어야 합니다). 썸네일은 표시방식이 「미리보기(이미지)」일 때 쇼핑몰에 보입니다.</p>' +
'<div class="cf24-side-actions"><span class="cf24-muted" id="cf24-opt-msg"></span>' +
' <button class="erp-btn erp-btn-primary" type="submit">옵션 저장</button></div>' +
"</form>" +
'<div class="cf24-opt-danger"><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-delete">옵션 전체 삭제</button></div>' +
"</section>";
// 품목 표 (모달 오른쪽 열) — 넓은 창이라 한 줄에 다 들어간다.
html += '<section class="cf24-modal-col cf24-modal-col-variants"><h4 class="cf24-side-title">품목 (' + variants.length + ')</h4>';
if (!variants.length) {
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>';
variants.forEach(function (v) {
var label = v.options.map(function (o) { return o.value; }).join(" / ") || v.variant_code;
var amount = String(parseInt(v.additional_amount || "0", 10) || 0);
html += '<tr class="cf24-v-item" data-code="' + escHtml(v.variant_code) + '">' +
'<td class="cf24-v-imgcol">' + (v.image ? '<img class="cf24-v-img" src="' + escHtml(v.image) + '" alt="" />' : '<span class="cf24-v-img cf24-v-img-empty"></span>') + "</td>" +
'<td class="cf24-v-name" title="' + escHtml(label) + '">' + escHtml(label) + "</td>" +
'<td class="cf24-v-syscode"><button type="button" class="cf24-v-copy" data-copy-text="' + escHtml(v.variant_code) + '" title="클릭하면 품목코드를 복사합니다">' + escHtml(v.variant_code) + "</button></td>" +
'<td class="cf24-v-code"><input type="text" name="custom_variant_code" maxlength="40" value="' + escHtml(v.custom_variant_code) + '" /></td>' +
'<td class="cf24-v-amount"><input type="text" name="additional_amount" inputmode="numeric" value="' + escHtml(amount) + '" /></td>' +
'<td class="cf24-v-flag"><button type="button" class="cf24-v-toggle' + (v.display ? " is-on" : "") + '" data-flag="display" data-on="' + (v.display ? 1 : 0) + '" data-initial="' + (v.display ? 1 : 0) + '">' + (v.display ? "진열" : "미진열") + "</button></td>" +
'<td class="cf24-v-flag"><button type="button" class="cf24-v-toggle' + (v.selling ? " is-on" : "") + '" data-flag="selling" data-on="' + (v.selling ? 1 : 0) + '" data-initial="' + (v.selling ? 1 : 0) + '">' + (v.selling ? "판매" : "중지") + "</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>';
}
html += "</section></div>";
body.innerHTML = html;
var optForm = body.querySelector("#cf24-opt-form");
trackDirty(optForm);
bindOptionImages(optForm);
optForm.addEventListener("submit", function (e) { e.preventDefault(); saveOptions(optForm); });
bindVariants();
body.querySelector("#cf24-opt-delete").addEventListener("click", deleteOptions);
}
function bindOptionImages(optForm) {
optForm.querySelectorAll(".cf24-opt-value").forEach(function (row) {
var fileEl = row.querySelector('input[type="file"]');
fileEl.addEventListener("change", function () {
var f = fileEl.files && fileEl.files[0];
if (!f) return;
var slot = row.querySelector(".cf24-opt-thumb-slot");
var msg = optForm.querySelector("#cf24-opt-msg");
setMsg(msg, "썸네일 업로드 중…", "");
var fd = new FormData();
fd.append("file", f);
apiJson("POST", "/cafe24/products/" + no + "/options/image", fd)
.then(function (data) {
row.querySelector('[name="option_image_file"]').value = data.path;
row.querySelector('[name="option_link_image"]').value = data.path;
slot.innerHTML = '<img class="cf24-opt-thumb is-dirty" src="' + escHtml(data.path) + '" alt="" />';
setMsg(msg, "썸네일을 올렸습니다. 「옵션 저장」을 눌러야 카페24에 반영됩니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "썸네일 업로드 실패: " + err.message, "is-err"); })
.then(function () { fileEl.value = ""; });
});
});
}
function saveOptions(optForm) {
var msg = optForm.querySelector("#cf24-opt-msg");
var edited = [];
var changed = false;
optForm.querySelectorAll(".cf24-opt-group[data-group]").forEach(function (g, gi) {
var orig = state.option.options[gi];
var entry = {
option_name: g.querySelector('[name="option_name"]').value.trim(),
option_display_type: g.querySelector('[name="option_display_type"]').value,
option_value: []
};
if (entry.option_name !== orig.option_name || entry.option_display_type !== (orig.option_display_type || "S")) changed = true;
g.querySelectorAll(".cf24-opt-value").forEach(function (row, vi) {
var ov = orig.option_value[vi] || {};
var item = {
option_text: row.querySelector('[name="option_text"]').value.trim(),
option_image_file: row.querySelector('[name="option_image_file"]').value,
option_link_image: row.querySelector('[name="option_link_image"]').value,
option_color: ov.option_color || ""
};
if (item.option_text !== ov.option_text || item.option_image_file !== (ov.option_image_file || "") ||
item.option_link_image !== (ov.option_link_image || "")) changed = true;
entry.option_value.push(item);
});
edited.push(entry);
});
if (!changed) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
window.erpConfirm("옵션명·옵션값을 카페24에 바로 반영합니다. 계속할까요?").then(function (ok) {
if (!ok) return;
setMsg(msg, "저장 중…", "");
apiJson("PUT", "/cafe24/products/" + no + "/options", { options: edited, option_list_type: state.option.option_list_type })
.then(function (data) {
state.option = data.option;
state.variants = data.variants || state.variants;
render();
setMsg(body.querySelector("#cf24-opt-msg"), "카페24에 반영했습니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); });
});
}
function bindVariants() {
var saveBtn = body.querySelector("#cf24-var-save");
if (!saveBtn) return;
var wrap = body.querySelector(".cf24-variants-wrap");
trackDirty(wrap);
// 품목코드 클릭 = 클립보드 복사 (HTTPS 가 아니면 execCommand 로 대체)
wrap.querySelectorAll(".cf24-v-copy").forEach(function (btn) {
btn.addEventListener("click", function () {
var text = btn.dataset.copyText || btn.textContent;
var done = function () {
var old = btn.textContent;
btn.textContent = "복사됨";
btn.classList.add("is-copied");
setTimeout(function () { btn.textContent = old; btn.classList.remove("is-copied"); }, 1200);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done, function () { fallbackCopy(text, done); });
} else {
fallbackCopy(text, done);
}
});
});
function fallbackCopy(text, done) {
var ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { if (document.execCommand("copy")) done(); } catch (e) { /* 복사 불가 — 조용히 무시 */ }
document.body.removeChild(ta);
}
wrap.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
btn.addEventListener("click", function () {
var on = btn.dataset.on !== "1";
btn.dataset.on = on ? "1" : "0";
btn.classList.toggle("is-on", on);
btn.textContent = btn.dataset.flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
btn.classList.toggle("is-dirty", btn.dataset.on !== btn.dataset.initial);
});
});
saveBtn.addEventListener("click", function () {
var msg = body.querySelector("#cf24-var-msg");
var rows = [];
var lines = [];
wrap.querySelectorAll(".cf24-v-item[data-code]").forEach(function (tr) {
var row = { variant_code: tr.dataset.code };
var any = false;
var code = tr.querySelector('[name="custom_variant_code"]');
var amount = tr.querySelector('[name="additional_amount"]');
if (code.value !== code.defaultValue) { row.custom_variant_code = code.value.trim(); any = true; }
if (amount.value !== amount.defaultValue) { row.additional_amount = amount.value.trim() || "0"; any = true; }
tr.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
if (btn.dataset.on !== btn.dataset.initial) { row[btn.dataset.flag] = btn.dataset.on === "1" ? "on" : "off"; any = true; }
});
if (any) {
rows.push(row);
lines.push(tr.querySelector(".cf24-v-name").textContent.trim());
}
});
if (!rows.length) { setMsg(msg, "바뀐 품목이 없습니다.", ""); return; }
window.erpConfirm("품목 " + rows.length + "건을 카페24에 바로 반영합니다. 계속할까요?\n\n" + lines.slice(0, 8).join("\n") + (lines.length > 8 ? "\n…" : ""))
.then(function (ok) {
if (!ok) return;
saveBtn.disabled = true;
setMsg(msg, "저장 중…", "");
apiJson("PUT", "/cafe24/products/" + no + "/variants", { rows: rows })
.then(function (data) {
var updated = data.updated || {};
Object.keys(updated).forEach(function (code) {
var u = updated[code];
var tr = wrap.querySelector('.cf24-v-item[data-code="' + code + '"]');
if (!tr) return;
var codeEl = tr.querySelector('[name="custom_variant_code"]');
var amountEl = tr.querySelector('[name="additional_amount"]');
if (u.custom_variant_code !== undefined) codeEl.value = u.custom_variant_code;
if (u.additional_amount !== undefined) amountEl.value = String(parseInt(u.additional_amount, 10) || 0);
tr.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
var flag = btn.dataset.flag;
if (u[flag] === undefined) return;
var on = !!u[flag];
btn.dataset.on = on ? "1" : "0";
btn.dataset.initial = btn.dataset.on;
btn.classList.toggle("is-on", on);
btn.classList.remove("is-dirty");
btn.textContent = flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
});
// 상태 보관 — 접었다 펴도 응답값이 유지되게
state.variants.forEach(function (v) {
if (v.variant_code !== code) 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;
});
});
commitValues(wrap);
setMsg(msg, "카페24에 반영했습니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); })
.then(function () { saveBtn.disabled = false; });
});
});
}
function deleteOptions() {
window.erpConfirm("옵션을 전부 삭제합니다. 카페24가 이 상품의 **품목도 모두 삭제**하며 되돌릴 수 없습니다.\n정말 삭제할까요?")
.then(function (ok) {
if (!ok) return;
body.innerHTML = '<p class="cf24-muted">삭제 중…</p>';
apiJson("DELETE", "/cafe24/products/" + no + "/options")
.then(function (data) { state.option = data.option; state.variants = []; render(); })
.catch(function (err) {
body.innerHTML = '<p class="cf24-err">삭제 실패: ' + escHtml(err.message) + "</p>";
loaded = false;
});
});
}
})();
}; };
// 공용 확인창(erp-dialog.js)은 비동기다 — 기본 confirm() 과 달리 Promise 를 // 공용 확인창(erp-dialog.js)은 비동기다 — 기본 confirm() 과 달리 Promise 를
+27
View File
@@ -1094,6 +1094,33 @@ def test_build_variant_updates():
pass pass
else: else:
raise AssertionError("bad code accepted") raise AssertionError("bad code accepted")
# 드래그 정렬 → display_order (1~300)
assert store.build_variant_updates([{"variant_code": "P000000R000A", "display_order": "3"}]) == [
{"variant_code": "P000000R000A", "display_order": 3}
]
for bad in ("0", "301", "x"):
try:
store.build_variant_updates([{"variant_code": "P000000R000A", "display_order": bad}])
except ValueError:
pass
else:
raise AssertionError(bad)
def test_parse_option_values_accepts_list():
assert store.parse_option_values(["빨강, 큰 것", " 파랑 ", "", "빨강, 큰 것"]) == ["빨강, 큰 것", "파랑"]
def test_wait_for_variants_retries_until_expected():
calls = {"n": 0}
class _Client:
def get(self, path, **kw):
calls["n"] += 1
return {"variants": [{"variant_code": "A"}] * (2 if calls["n"] >= 3 else 1)}
out = products.wait_for_variants(_Client(), 7, 2, attempts=5)
assert len(out) == 2 and calls["n"] == 3
class _RouteClient: class _RouteClient:
+785
View File
@@ -0,0 +1,785 @@
/* 카페24 상품관리 — 옵션/품목 모달 (products.html 에서 window.cf24SetupOptions 로 호출).
통합 목록: 옵션이 1개(옵션명 하나)인 상품은 옵션값과 품목이 1:1 이므로 한 행에
썸네일 · 옵션값 이름 · 품목코드 · 자체 품목코드 · 추가금액 · 진열 · 판매 를 놓고
「저장」 한 번으로 옵션(PUT options) → 품목(PUT variants) 순서로 반영한다.
옵션이 없는 상품: 행을 자유롭게 추가/삭제해 두고 저장하면 옵션 생성(POST options,
카페24가 품목을 자동 생성) → 부여된 품목코드를 받아 → 자체코드/추가금액을 품목에
PUT → 썸네일이 있으면 옵션 PUT. 저장 뒤 다시 읽어 품목코드가 바로 보인다.
옵션이 2개 이상(조합)인 상품: 옵션값과 품목이 1:1 이 아니므로 예전 2열 화면
(왼쪽 옵션 정의 / 오른쪽 품목 표)을 그대로 쓴다.
순서 드래그: 품목의 display_order(카페24 문서 — 조합형 품목 진열순서)로 저장한다.
옵션값 배열 자체를 재배열해 PUT 하지 않는다 — 카페24는 original_options 와
options 를 **위치**로 짝지어 이름을 바꾸므로, 재배열하면 품목코드와 이름이
뒤바뀔 위험이 있다(품목코드는 주문 이력이 참조한다).
옵션값 불러오기: 탭/공백으로 나뉜 텍스트(이름 · 가격 · 추가금액 · 자체코드)를
붙여넣으면 행에 채운다. 가격 열은 무시한다.
화면은 언제나 **응답값**으로 다시 그린다(카페24 GET 은 쓰기 직후 예전 값을 돌려줄
수 있어, 서버가 스냅샷으로 보정한다). */
(function () {
"use strict";
var DEFAULT_TYPE = "S";
window.cf24SetupOptions = function (ctx) {
var root = ctx.root, no = ctx.productNo, modal = ctx.modal;
var apiJson = ctx.apiJson, esc = ctx.escHtml, setMsg = ctx.setMsg;
var trackDirty = ctx.trackDirty, commitValues = ctx.commitValues;
var openBtn = root.querySelector("#cf24-options-open");
var body = modal.body;
var count = root.querySelector("#cf24-options-count");
if (!openBtn || !body) return;
var state = { option: null, variants: [], displayTypes: {} };
openBtn.addEventListener("click", function () {
var nameEl = document.getElementById("cf24-name-text");
modal.open((nameEl ? nameEl.textContent : "") + " (상품번호 " + no + ")");
load();
});
function load() {
body.innerHTML = '<p class="cf24-muted">카페24에서 불러오는 중…</p>';
return apiJson("GET", "/cafe24/products/" + no + "/options")
.then(function (data) {
state.option = data.option;
state.variants = data.variants || [];
state.displayTypes = data.display_types || {};
render();
})
.catch(function (err) {
body.innerHTML = '<p class="cf24-err">옵션을 불러오지 못했습니다: ' + esc(err.message) +
'</p><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-retry">다시 시도</button>';
body.querySelector("#cf24-opt-retry").addEventListener("click", load);
});
}
/* ── 공통 조각 ── */
function typeSelect(current, name) {
var out = '<select name="' + name + '">';
Object.keys(state.displayTypes).forEach(function (k) {
out += '<option value="' + k + '"' + (k === current ? " selected" : "") + ">" + esc(state.displayTypes[k]) + "</option>";
});
return out + "</select>";
}
function toggleBtn(flag, on) {
var label = flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
return '<button type="button" class="cf24-v-toggle' + (on ? " is-on" : "") + '" data-flag="' + flag +
'" data-on="' + (on ? 1 : 0) + '" data-initial="' + (on ? 1 : 0) + '">' + label + "</button>";
}
function paintToggle(btn, on, resetInitial) {
btn.dataset.on = on ? "1" : "0";
if (resetInitial) btn.dataset.initial = btn.dataset.on;
btn.classList.toggle("is-on", on);
btn.classList.toggle("is-dirty", btn.dataset.on !== btn.dataset.initial);
btn.textContent = btn.dataset.flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
}
function bindToggles(container) {
container.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
btn.addEventListener("click", function () { paintToggle(btn, btn.dataset.on !== "1", false); });
});
}
function copyText(text, done) {
function fallback() {
var ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { if (document.execCommand("copy")) done(); } catch (e) { /* 복사 불가 */ }
document.body.removeChild(ta);
}
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done, fallback);
} else {
fallback();
}
}
function bindCopy(container) {
container.querySelectorAll(".cf24-v-copy").forEach(function (btn) {
btn.addEventListener("click", function () {
copyText(btn.dataset.copyText || btn.textContent, function () {
var old = btn.textContent;
btn.textContent = "복사됨";
btn.classList.add("is-copied");
setTimeout(function () { btn.textContent = old; btn.classList.remove("is-copied"); }, 1200);
});
});
});
}
// 옵션값 썸네일 — 버튼 이미지 → 연결 이미지 → 그 옵션값을 가진 품목 image
function variantImageFor(name, value) {
for (var i = 0; i < state.variants.length; i++) {
var v = state.variants[i];
if (!v.image) continue;
for (var j = 0; j < v.options.length; j++) {
if (v.options[j].name === name && v.options[j].value === value) return v.image;
}
}
return "";
}
function thumbHtml(src, title) {
return src
? '<img class="cf24-opt-thumb" src="' + esc(src) + '" alt="" title="' + esc(title || "") + '" />'
: '<div class="cf24-opt-thumb-empty">없음</div>';
}
// 썸네일 파일 선택 → 업로드 → hidden 두 개(option_image_file, option_link_image)에 경로
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;
fileEl.addEventListener("change", function () {
var f = fileEl.files && fileEl.files[0];
if (!f) return;
var slot = row.querySelector(".cf24-opt-thumb-slot");
setMsg(msgEl, "썸네일 업로드 중…", "");
var fd = new FormData();
fd.append("file", f);
apiJson("POST", "/cafe24/products/" + no + "/options/image", fd)
.then(function (data) {
row.querySelector('[name="option_image_file"]').value = data.path;
row.querySelector('[name="option_link_image"]').value = data.path;
slot.innerHTML = '<img class="cf24-opt-thumb is-dirty" src="' + esc(data.path) + '" alt="" />';
row.classList.add("is-image-dirty");
setMsg(msgEl, "썸네일을 올렸습니다. 「저장」을 눌러야 카페24에 반영됩니다.", "is-ok");
})
.catch(function (err) { setMsg(msgEl, "썸네일 업로드 실패: " + err.message, "is-err"); })
.then(function () { fileEl.value = ""; });
});
});
}
function amountPlain(v) { return String(parseInt(String(v || "0").replace(/,/g, ""), 10) || 0); }
/* ═══════════════════════════════════════════════════════════
화면 분기
═══════════════════════════════════════════════════════════ */
function render() {
var opt = state.option || { has_option: false, options: [] };
if (count) count.textContent = opt.has_option ? "(" + state.variants.length + "품목)" : "(옵션 없음)";
if (!opt.has_option) return renderUnified(true);
if (opt.options.length === 1) return renderUnified(false);
return renderMulti();
}
/* ═══════════════════════════════════════════════════════════
통합 목록 — 옵션값 1 : 품목 1
═══════════════════════════════════════════════════════════ */
function unifiedRows() {
var opt = state.option;
if (!opt || !opt.has_option) return [];
var group = opt.options[0];
return group.option_value.map(function (v, vi) {
var variant = null;
for (var i = 0; i < state.variants.length; i++) {
var cand = state.variants[i];
if (cand.options.length && cand.options[0].value === v.option_text) { variant = cand; break; }
}
return { index: vi, value: v, variant: variant };
});
}
function rowHtml(r, isNew, canDrag) {
var v = r.value || { option_text: "", option_image_file: "", option_link_image: "", option_color: "" };
var variant = r.variant;
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 : "";
return '<tr class="cf24-u-row" data-index="' + (r.index == null ? "" : r.index) + '" 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 || "") + '" />' +
'<input type="hidden" name="option_link_image" value="' + esc(v.option_link_image || "") + '" />' +
'<label class="cf24-file-pick cf24-u-pick" title="썸네일 업로드"><input type="file" accept="image/jpeg,image/png,image/gif,image/webp" /><span class="erp-btn erp-btn-outline">썸네일</span></label></td>' +
'<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>" +
'<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>" +
"</tr>";
}
function renderUnified(isNew) {
var opt = state.option;
var group = isNew ? { option_name: "", option_display_type: DEFAULT_TYPE } : opt.options[0];
var rows = isNew ? [] : unifiedRows();
// 순서 변경은 조합형(T) 품목의 display_order 로만 저장할 수 있다.
var canDrag = isNew || opt.option_type === "T";
var html = '<div class="cf24-u-wrap">' +
'<div class="cf24-u-head">' +
' <label class="cf24-side-field cf24-u-optname"><span>옵션명</span><input type="text" name="option_name" value="' + esc(group.option_name) + '" placeholder="예: 구성" /></label>' +
' <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>' : "") +
" </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) { html += rowHtml(r, false, canDrag); });
if (isNew) html += rowHtml({ index: null, value: null, variant: null }, true, true);
html += "</tbody></table></div>" +
'<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> ') +
' <button type="button" class="erp-btn erp-btn-primary" id="cf24-u-save">저장</button></span></div>' +
"</div>";
body.innerHTML = html;
var wrap = body.querySelector(".cf24-u-wrap");
var tbody = body.querySelector("#cf24-u-body");
var msg = body.querySelector("#cf24-u-msg");
trackDirty(wrap);
bindToggles(tbody);
bindCopy(tbody);
bindThumbUploads(tbody, msg);
if (canDrag) bindDrag(tbody);
if (isNew) {
body.querySelector("#cf24-u-add").addEventListener("click", function () {
tbody.insertAdjacentHTML("beforeend", rowHtml({ index: null, value: null, variant: null }, true, true));
var tr = tbody.lastElementChild;
bindRowNew(tr, tbody, msg);
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);
}
function bindRowNew(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; }
tr.remove();
});
}
trackDirty(tr);
bindToggles(tr);
bindThumbUploads(tr, msg);
}
/* ── 드래그 정렬 (HTML5 DnD, 핸들에서만 시작) ── */
function bindDrag(tbody) {
var dragging = null;
tbody.querySelectorAll(".cf24-drag-handle").forEach(function (h) {
h.addEventListener("dragstart", function (e) {
dragging = h.closest("tr");
dragging.classList.add("is-dragging");
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", dragging.dataset.code || "row"); } catch (err) { /* IE */ }
});
h.addEventListener("dragend", function () {
if (dragging) dragging.classList.remove("is-dragging");
dragging = null;
markOrder(tbody);
});
});
tbody.addEventListener("dragover", function (e) {
if (!dragging) return;
e.preventDefault();
var over = e.target.closest("tr");
if (!over || over === dragging) return;
var rect = over.getBoundingClientRect();
var after = (e.clientY - rect.top) > rect.height / 2;
tbody.insertBefore(dragging, after ? over.nextSibling : over);
});
tbody.addEventListener("drop", function (e) { e.preventDefault(); });
}
// 순서가 처음과 달라졌는지 표시(저장 시 display_order 전송 여부)
function markOrder(tbody) {
var changed = false;
Array.prototype.forEach.call(tbody.children, function (tr, i) {
var orig = tr.dataset.index === "" ? null : parseInt(tr.dataset.index, 10);
if (orig !== null && orig !== i) changed = true;
});
tbody.classList.toggle("is-order-dirty", changed);
Array.prototype.forEach.call(tbody.children, function (tr) {
var h = tr.querySelector(".cf24-drag-handle");
if (h) h.classList.toggle("is-dirty", changed);
});
}
/* ── 옵션값 불러오기 (텍스트 붙여넣기) ── */
function parseImport(text) {
var out = [];
String(text || "").split(/\r?\n/).forEach(function (line) {
if (!line.trim()) return;
var cols = line.indexOf("\t") >= 0 ? line.split("\t") : line.split(/\s{2,}/);
cols = cols.map(function (c) { return c.trim(); });
while (cols.length && !cols[cols.length - 1]) cols.pop();
if (!cols.length) return;
var name = cols.shift();
if (!name) return;
var code = "";
if (cols.length && /[A-Za-z가-힣]/.test(cols[cols.length - 1])) code = cols.pop();
var nums = cols.map(function (c) {
var t = c.replace(/[,\s원]/g, "");
if (t === "-" || t === "") return 0;
var n = parseInt(t, 10);
return isNaN(n) ? null : n;
}).filter(function (n) { return n !== null; });
// 열이 2개 이상이면 첫 번째는 판매가(무시), 마지막이 추가금액
var amount = nums.length >= 2 ? nums[nums.length - 1] : (nums.length === 1 ? nums[0] : null);
out.push({ name: name, amount: amount, code: code });
});
return out;
}
function openImport(isNew, tbody, msg) {
var dlg = document.createElement("div");
dlg.className = "cf24-modal cf24-import";
dlg.innerHTML = '<div class="cf24-modal-box cf24-import-box" role="dialog" aria-modal="true">' +
'<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>' +
'<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>' +
'<button type="button" class="erp-btn erp-btn-outline cf24-import-cancel">취소</button> ' +
'<button type="button" class="erp-btn erp-btn-primary cf24-import-apply">적용</button></span></div>' +
"</div></div>";
document.body.appendChild(dlg);
var ta = dlg.querySelector("textarea");
var preview = dlg.querySelector(".cf24-import-preview");
function close() { dlg.remove(); }
dlg.querySelector(".cf24-modal-close").addEventListener("click", close);
dlg.querySelector(".cf24-import-cancel").addEventListener("click", close);
dlg.addEventListener("click", function (e) { if (e.target === dlg) close(); });
ta.addEventListener("input", function () {
var parsed = parseImport(ta.value);
preview.textContent = parsed.length
? parsed.length + "행 인식 — 예: " + parsed[0].name + " / 추가금액 " + (parsed[0].amount == null ? "(없음)" : parsed[0].amount.toLocaleString("ko-KR")) + " / 코드 " + (parsed[0].code || "(없음)")
: "";
});
dlg.querySelector(".cf24-import-apply").addEventListener("click", function () {
var parsed = parseImport(ta.value);
if (!parsed.length) { window.erpAlert("인식된 줄이 없습니다."); return; }
applyImport(parsed, isNew, tbody, msg);
close();
});
ta.focus();
}
function setRowValues(tr, item) {
var nameEl = tr.querySelector('[name="option_text"]');
var codeEl = tr.querySelector('[name="custom_variant_code"]');
var amtEl = tr.querySelector('[name="additional_amount"]');
if (item.name != null && nameEl) { nameEl.value = item.name; nameEl.dispatchEvent(new Event("input")); }
if (item.code && codeEl) { codeEl.value = item.code; codeEl.dispatchEvent(new Event("input")); }
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 applyImport(parsed, isNew, tbody, msg) {
if (isNew) {
// 비어 있는 행만 있으면 교체, 내용이 있으면 뒤에 덧붙인다
var blank = Array.prototype.every.call(tbody.children, function (tr) {
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);
});
setMsg(msg, parsed.length + "행을 채웠습니다. 「저장」을 누르면 카페24에 반영됩니다.", "is-ok");
return;
}
var rows = Array.prototype.slice.call(tbody.children);
var matched = 0, missing = [];
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++;
});
var text = matched + "행에 자체코드·추가금액을 채웠습니다.";
if (missing.length) text += " 일치하는 옵션값이 없어 건너뜀: " + missing.join(", ") + " (옵션값 추가는 API 미지원)";
setMsg(msg, text, missing.length ? "is-err" : "is-ok");
}
/* ── 저장 (통합) ── */
function collectUnified(tbody) {
return Array.prototype.map.call(tbody.children, function (tr, i) {
return {
tr: tr,
position: i,
index: tr.dataset.index === "" ? null : parseInt(tr.dataset.index, 10),
code: tr.dataset.code || "",
text: tr.querySelector('[name="option_text"]').value.trim(),
image: tr.querySelector('[name="option_image_file"]').value,
link: tr.querySelector('[name="option_link_image"]').value,
custom: tr.querySelector('[name="custom_variant_code"]'),
amount: tr.querySelector('[name="additional_amount"]'),
display: tr.querySelector('.cf24-v-toggle[data-flag="display"]'),
selling: tr.querySelector('.cf24-v-toggle[data-flag="selling"]')
};
});
}
function validateNames(rows) {
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;
}
return "";
}
function variantPatch(r, withOrder) {
var patch = { variant_code: r.code };
var any = false;
if (r.custom.value !== r.custom.defaultValue) { patch.custom_variant_code = r.custom.value.trim(); any = true; }
if (r.amount.value !== r.amount.defaultValue) { patch.additional_amount = r.amount.value.trim() || "0"; any = true; }
if (r.display.dataset.on !== r.display.dataset.initial) { patch.display = r.display.dataset.on === "1" ? "on" : "off"; any = true; }
if (r.selling.dataset.on !== r.selling.dataset.initial) { patch.selling = r.selling.dataset.on === "1" ? "on" : "off"; any = true; }
if (withOrder) { patch.display_order = r.position + 1; any = true; }
return any ? patch : null;
}
function saveUnified(isNew, tbody, msg) {
var rows = collectUnified(tbody);
var problem = validateNames(rows);
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");
if (isNew) {
window.erpConfirm("옵션 「" + optName + "」과 옵션값 " + rows.length + "개를 만들고 품목을 생성합니다.\n카페24에 바로 반영됩니다. 계속할까요?")
.then(function (ok) { if (ok) createFlow(); });
return;
}
// 기존 옵션 — 옵션(PUT) 은 **원래 순서**로 짝을 맞춰 보낸다(재배열은 display_order 로).
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) {
var r = null;
for (var i = 0; i < rows.length; i++) if (rows[i].index === vi) { r = rows[i]; break; }
if (!r) return { option_text: ov.option_text, option_image_file: ov.option_image_file, option_link_image: ov.option_link_image, option_color: ov.option_color };
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 orderChanged = tbody.classList.contains("is-order-dirty");
var patches = [];
rows.forEach(function (r) {
if (!r.code) return;
var p = variantPatch(r, orderChanged);
if (p) patches.push(p);
});
if (!optionsChanged && !patches.length) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
var lines = [];
if (optionsChanged) lines.push("옵션명·옵션값 이름/썸네일/표시방식");
if (patches.length) lines.push("품목 " + patches.length + "건 (자체코드·추가금액·진열/판매" + (orderChanged ? "·순서" : "") + ")");
window.erpConfirm("카페24에 바로 반영합니다. 계속할까요?\n\n- " + lines.join("\n- ")).then(function (ok) {
if (!ok) return;
saveBtn.disabled = true;
setMsg(msg, "저장 중…", "");
var chain = Promise.resolve();
if (optionsChanged) {
chain = chain.then(function () {
return apiJson("PUT", "/cafe24/products/" + no + "/options", {
options: [{ option_name: optName, option_display_type: optType, option_value: edited }],
option_list_type: state.option.option_list_type
}).then(function (data) {
state.option = data.option;
if (data.variants) state.variants = data.variants;
});
});
}
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 (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 () {
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);
})
.then(function () { saveBtn.disabled = false; });
});
function createFlow() {
saveBtn.disabled = true;
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
})
.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 patches = [];
var unmatched = 0;
rows.forEach(function (r) {
var code = byValue[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 chain = Promise.resolve();
if (patches.length) {
setMsg(msg, "품목 " + patches.length + "건에 자체코드·추가금액 반영 중…", "");
chain = chain.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 () {
setMsg(msg, "썸네일 반영 중…", "");
var edited = state.option.options[0].option_value.map(function (ov) {
var r = null;
for (var i = 0; i < rows.length; i++) if (rows[i].text === ov.option_text) { r = rows[i]; break; }
return { option_text: ov.option_text, option_image_file: r ? r.image : "", option_link_image: r ? r.link : "", option_color: "" };
});
return apiJson("PUT", "/cafe24/products/" + no + "/options", {
options: [{ option_name: optName, option_display_type: optType, option_value: edited }],
option_list_type: state.option.option_list_type
});
});
}
return chain.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");
});
})
.catch(function (err) {
setMsg(msg, "실패: " + err.message, "is-err");
saveBtn.disabled = false;
});
}
}
/* ═══════════════════════════════════════════════════════════
옵션 2개 이상(조합) — 예전 2열 화면
═══════════════════════════════════════════════════════════ */
function renderMulti() {
var opt = state.option;
var variants = state.variants;
var html = '<div class="cf24-modal-grid"><section class="cf24-modal-col cf24-modal-col-options">' +
'<h4 class="cf24-side-title">옵션 정의</h4><form class="cf24-side-form" id="cf24-opt-form">';
opt.options.forEach(function (o, gi) {
html += '<div class="cf24-opt-group" data-group="' + gi + '"><div class="cf24-opt-head">' +
'<label class="cf24-side-field"><span>옵션명</span><input type="text" name="option_name" value="' + esc(o.option_name) + '" /></label>' +
'<label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect(o.option_display_type || DEFAULT_TYPE, "option_display_type") + "</label></div>";
o.option_value.forEach(function (v, vi) {
var src = v.option_image_file || v.option_link_image || variantImageFor(o.option_name, v.option_text);
html += '<div class="cf24-opt-value" data-value="' + vi + '"><span class="cf24-opt-thumb-slot">' + thumbHtml(src) + "</span>" +
'<input type="text" name="option_text" value="' + esc(v.option_text) + '" placeholder="옵션값 이름" />' +
'<input type="hidden" name="option_image_file" value="' + esc(v.option_image_file) + '" />' +
'<input type="hidden" name="option_link_image" value="' + esc(v.option_link_image) + '" />' +
'<label class="cf24-file-pick"><input type="file" accept="image/jpeg,image/png,image/gif,image/webp" /><span class="erp-btn erp-btn-outline">썸네일</span></label></div>';
});
html += "</div>";
});
html += '<p class="cf24-side-note">옵션이 2개 이상인 조합 상품입니다. 옵션값 추가·삭제는 카페24 API 가 지원하지 않습니다.</p>' +
'<div class="cf24-side-actions"><span class="cf24-muted" id="cf24-opt-msg"></span><button class="erp-btn erp-btn-primary" type="submit">옵션 저장</button></div></form>' +
'<div class="cf24-opt-danger"><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-delete">옵션 전체 삭제</button></div></section>';
html += '<section class="cf24-modal-col cf24-modal-col-variants"><h4 class="cf24-side-title">품목 (' + variants.length + ')</h4>';
if (!variants.length) {
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>';
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) + '">' +
'<td class="cf24-v-imgcol">' + (v.image ? '<img class="cf24-v-img" src="' + esc(v.image) + '" alt="" />' : '<span class="cf24-v-img cf24-v-img-empty"></span>') + "</td>" +
'<td class="cf24-v-name" title="' + esc(label) + '">' + esc(label) + "</td>" +
'<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>";
});
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>';
}
html += "</section></div>";
body.innerHTML = html;
var optForm = body.querySelector("#cf24-opt-form");
trackDirty(optForm);
bindThumbUploads(optForm, optForm.querySelector("#cf24-opt-msg"));
optForm.addEventListener("submit", function (e) { e.preventDefault(); saveMultiOptions(optForm); });
body.querySelector("#cf24-opt-delete").addEventListener("click", deleteOptions);
var wrap = body.querySelector(".cf24-variants-wrap");
if (wrap) {
trackDirty(wrap);
bindToggles(wrap);
bindCopy(wrap);
body.querySelector("#cf24-var-save").addEventListener("click", function () { saveMultiVariants(wrap); });
}
}
function saveMultiOptions(optForm) {
var msg = optForm.querySelector("#cf24-opt-msg");
var edited = [];
var changed = false;
optForm.querySelectorAll(".cf24-opt-group[data-group]").forEach(function (g, gi) {
var orig = state.option.options[gi];
var entry = { option_name: g.querySelector('[name="option_name"]').value.trim(),
option_display_type: g.querySelector('[name="option_display_type"]').value, option_value: [] };
if (entry.option_name !== orig.option_name || entry.option_display_type !== (orig.option_display_type || DEFAULT_TYPE)) changed = true;
g.querySelectorAll(".cf24-opt-value").forEach(function (row, vi) {
var ov = orig.option_value[vi] || {};
var item = { option_text: row.querySelector('[name="option_text"]').value.trim(),
option_image_file: row.querySelector('[name="option_image_file"]').value,
option_link_image: row.querySelector('[name="option_link_image"]').value,
option_color: ov.option_color || "" };
if (item.option_text !== ov.option_text || item.option_image_file !== (ov.option_image_file || "") || item.option_link_image !== (ov.option_link_image || "")) changed = true;
entry.option_value.push(item);
});
edited.push(entry);
});
if (!changed) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
window.erpConfirm("옵션명·옵션값을 카페24에 바로 반영합니다. 계속할까요?").then(function (ok) {
if (!ok) return;
setMsg(msg, "저장 중…", "");
apiJson("PUT", "/cafe24/products/" + no + "/options", { options: edited, option_list_type: state.option.option_list_type })
.then(function (data) {
state.option = data.option;
state.variants = data.variants || state.variants;
render();
setMsg(body.querySelector("#cf24-opt-msg"), "카페24에 반영했습니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); });
});
}
function saveMultiVariants(wrap) {
var msg = body.querySelector("#cf24-var-msg");
var saveBtn = body.querySelector("#cf24-var-save");
var rows = [];
wrap.querySelectorAll("tr[data-code]").forEach(function (tr) {
var r = { code: tr.dataset.code, custom: tr.querySelector('[name="custom_variant_code"]'), amount: tr.querySelector('[name="additional_amount"]'),
display: tr.querySelector('.cf24-v-toggle[data-flag="display"]'), selling: tr.querySelector('.cf24-v-toggle[data-flag="selling"]') };
var p = variantPatch(r, false);
if (p) rows.push(p);
});
if (!rows.length) { setMsg(msg, "바뀐 품목이 없습니다.", ""); return; }
window.erpConfirm("품목 " + rows.length + "건을 카페24에 바로 반영합니다. 계속할까요?").then(function (ok) {
if (!ok) return;
saveBtn.disabled = true;
setMsg(msg, "저장 중…", "");
apiJson("PUT", "/cafe24/products/" + no + "/variants", { rows: rows })
.then(function (data) {
var updated = data.updated || {};
Object.keys(updated).forEach(function (code) {
var u = updated[code];
var tr = wrap.querySelector('tr[data-code="' + code + '"]');
if (!tr) return;
if (u.custom_variant_code !== undefined) tr.querySelector('[name="custom_variant_code"]').value = u.custom_variant_code;
if (u.additional_amount !== undefined) tr.querySelector('[name="additional_amount"]').value = amountPlain(u.additional_amount);
tr.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
if (u[btn.dataset.flag] !== undefined) paintToggle(btn, !!u[btn.dataset.flag], true);
});
state.variants.forEach(function (v) {
if (v.variant_code !== code) 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;
});
});
commitValues(wrap);
setMsg(msg, "카페24에 반영했습니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); })
.then(function () { saveBtn.disabled = false; });
});
}
function deleteOptions() {
window.erpConfirm("옵션을 전부 삭제합니다. 카페24가 이 상품의 **품목도 모두 삭제**하며 되돌릴 수 없습니다.\n정말 삭제할까요?")
.then(function (ok) {
if (!ok) return;
body.innerHTML = '<p class="cf24-muted">삭제 중…</p>';
apiJson("DELETE", "/cafe24/products/" + no + "/options")
.then(function (data) { state.option = data.option; state.variants = []; render(); })
.catch(function (err) { body.innerHTML = '<p class="cf24-err">삭제 실패: ' + esc(err.message) + "</p>"; });
});
}
};
})();
+109
View File
@@ -1288,6 +1288,115 @@ body.cf24-modal-open {
color: var(--color-success-green, #10c22b); color: var(--color-success-green, #10c22b);
} }
/* ── 통합 목록(옵션값 1 : 품목 1) — 모달 전체 폭 사용, 표만 스크롤 ── */
.cf24-u-wrap {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
gap: var(--sp-10, 10px);
}
.cf24-u-head {
display: flex;
align-items: flex-end;
gap: var(--sp-10, 10px);
flex: 0 0 auto;
}
.cf24-u-optname { flex: 0 1 320px; }
.cf24-u-head-actions {
margin-left: auto;
display: flex;
gap: var(--sp-8, 8px);
align-items: flex-end;
}
.cf24-u-tablewrap {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
border-top: 1px solid var(--color-subtle-ash, #e5e5e5);
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
}
.cf24-u-note { flex: 0 0 auto; margin: 0; }
.cf24-u-actions { flex: 0 0 auto; }
.cf24-u-table .cf24-u-drag { width: 26px; text-align: center; }
.cf24-u-table .cf24-u-thumb { width: 118px; }
.cf24-u-table .cf24-u-del { width: 30px; text-align: center; }
.cf24-u-table .cf24-u-name input[type="text"] { font-weight: 500; }
.cf24-u-thumb .cf24-opt-thumb-slot { display: inline-block; vertical-align: middle; margin-right: 6px; }
.cf24-u-thumb .cf24-opt-thumb,
.cf24-u-thumb .cf24-opt-thumb-empty { width: 40px; height: 40px; }
.cf24-u-pick { display: inline-flex; vertical-align: middle; }
.cf24-u-pick .erp-btn { padding: 3px 8px; font-size: 12px; }
.cf24-drag-handle {
cursor: grab;
color: var(--color-midtone-gray, #737373);
font-size: 14px;
user-select: none;
padding: 4px 2px;
letter-spacing: -1px;
}
.cf24-drag-handle:active { cursor: grabbing; }
.cf24-drag-handle.is-dirty { color: #f0b429; }
tr.cf24-u-row.is-dragging {
opacity: 0.45;
background: #fffbea;
}
.cf24-u-remove {
border: 0;
background: transparent;
color: var(--color-midtone-gray, #737373);
cursor: pointer;
font-size: 13px;
padding: 4px;
border-radius: var(--r-sm, 4px);
}
.cf24-u-remove:hover {
background: #fdefec;
color: var(--color-callout-red, #c22b10);
}
.cf24-btn-danger {
color: var(--color-callout-red, #c22b10);
border-color: var(--color-callout-red, #c22b10);
}
/* 옵션값 불러오기 대화상자 — 옵션 모달 위에 한 겹 더 */
.cf24-import { z-index: 1100; }
.cf24-import-box {
width: min(720px, 94vw);
height: auto;
max-height: 80vh;
}
.cf24-import-text {
width: 100%;
box-sizing: border-box;
min-height: 220px;
padding: var(--sp-10, 10px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-md, 6px);
font-family: var(--font-geist-mono, ui-monospace, monospace);
font-size: 12px;
line-height: 1.6;
white-space: pre;
resize: vertical;
}
.cf24-import-preview { margin: 6px 0 10px; min-height: 16px; }
.cf24-v-img { .cf24-v-img {
width: 32px; width: 32px;
height: 32px; height: 32px;
+20 -2
View File
@@ -149,7 +149,7 @@ Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 방법이 없다(`config.p
상품 클릭 시 `/products/{no}/pane` 이 편집기 조각과 패널 조각을 **한 응답**(`_panes.html`, 상품 클릭 시 `/products/{no}/pane` 이 편집기 조각과 패널 조각을 **한 응답**(`_panes.html`,
`[data-pane=editor]`/`[data-pane=side]`)으로 돌려주고 JS 가 각각 끼운다 — 카페24 상품 `[data-pane=editor]`/`[data-pane=side]`)으로 돌려주고 JS 가 각각 끼운다 — 카페24 상품
조회는 1회. 옵션/품목은 패널의 「팝업에서 관리」 버튼으로 **모달**(`#cf24-options-modal`, 조회는 1회. 옵션/품목은 패널의 「팝업에서 관리」 버튼으로 **모달**(`#cf24-options-modal`,
최대 1280px·높이 90vh — 왼쪽 옵션 정의(340px) / 오른쪽 품목 표, 두 열 모두 창 바닥까지 각자 스크롤. 품목코드 클릭 = 클립보드 복사)을 열 때 JSON 으로 따로 불러온다(호출 2회 최대 1280px·높이 90vh — 옵션 1개 상품은 옵션값+품목 통합 목록, 조합 상품은 왼쪽 옵션 정의(340px) / 오른쪽 품목 표. 품목코드 클릭 = 클립보드 복사. 로직은 `app/static/cafe24-options.js`)을 열 때 JSON 으로 따로 불러온다(호출 2회
절약). 380px 패널은 품목 표를 담기에 좁아(실측) 별도 창으로 뺐다. 모달은 페이지에 하나만 절약). 380px 패널은 품목 표를 담기에 좁아(실측) 별도 창으로 뺐다. 모달은 페이지에 하나만
두고(패널 조각 교체와 무관) 다른 상품을 고르면 닫힌다. 저장 안 한 변경(`.is-dirty`)이 두고(패널 조각 교체와 무관) 다른 상품을 고르면 닫힌다. 저장 안 한 변경(`.is-dirty`)이
있으면 닫을 때 확인창. 있으면 닫을 때 확인창.
@@ -462,7 +462,25 @@ cafe24_oauth_tokens 저장
- "옵션별 상품 이름" 은 옵션값(`option_text`)이고, "옵션별 썸네일" 은 `option_image_file` - "옵션별 상품 이름" 은 옵션값(`option_text`)이고, "옵션별 썸네일" 은 `option_image_file`
(+ `option_link_image` 에도 같은 경로) 이며, (+ `option_link_image` 에도 같은 경로) 이며,
화면의 옵션값 썸네일은 **`option_image_file` → `option_link_image` → 그 옵션값을 가진 화면의 옵션값 썸네일은 **`option_image_file` → `option_link_image` → 그 옵션값을 가진
품목의 `image`** 순으로 있는 것을 보여준다. 카페24 관리자에서 "옵션 연결 이미지"로만 품목의 `image`** 순으로 있는 것을 보여준다.
- **통합 목록**(`app/static/cafe24-options.js`): 옵션이 1개(옵션명 하나)인 상품은 옵션값과
품목이 1:1 이므로 한 행에 드래그핸들 · 썸네일 · 옵션값 이름 · 품목코드(클릭 복사) ·
자체 품목코드 · 추가금액 · 진열 · 판매를 두고 「저장」 한 번에 옵션 PUT(이름/썸네일/
표시방식, **원래 순서로** 짝지어 전송) → 품목 PUT(자체코드/추가금액/진열/판매/순서)
순으로 반영한다. 옵션이 없는 상품은 행을 추가/삭제해 두고 저장하면 옵션 생성(POST) →
카페24가 부여한 품목코드를 `products.wait_for_variants`(읽기 지연 재시도)로 받아 →
자체코드/추가금액을 품목 PUT → 썸네일이 있으면 옵션 PUT 까지 이어서 한다.
옵션이 2개 이상(조합)인 상품은 1:1 이 아니라 2열 화면(옵션 정의 / 품목 표)을 쓴다.
- **순서 드래그**는 품목의 `display_order`(문서: 조합형 품목 진열순서 1~300)로 저장한다.
옵션값 배열을 재배열해 PUT 하지 **않는다** — 카페24는 `original_options` 와 `options`
를 위치로 짝지어 이름을 바꾸므로, 재배열하면 품목코드와 이름이 뒤바뀔 수 있다
(품목코드는 주문 이력이 참조한다). 조합형(T)이 아닌 옵션은 핸들을 숨긴다.
카페24 GET variants 응답에 `display_order` 가 없어 다시 열었을 때 순서는 카페24가
돌려주는 순서를 따른다.
- **옵션값 불러오기**: 텍스트(탭 또는 두 칸 이상 공백 구분 — 이름 · 판매가 · 추가금액 ·
자체코드, 판매가는 무시, 추가금액 「-」는 0)를 붙여넣어 행에 채운다. 옵션이 있는 상품은
이름(공백 무시)으로 짝을 찾아 자체코드/추가금액만 채우고, 없는 이름은 건너뛴다(옵션값
추가는 API 미지원). 카페24 관리자에서 "옵션 연결 이미지"로만
등록한 상품은 `option_image_file` 이 비어 있고 품목 `image` 에만 나타난다(실물 — 등록한 상품은 `option_image_file` 이 비어 있고 품목 `image` 에만 나타난다(실물 —
버튼 이미지만 보면 "없음"으로 오인했던 사고). 품목은 모달 오른쪽 열에 표(이미지 · 버튼 이미지만 보면 "없음"으로 오인했던 사고). 품목은 모달 오른쪽 열에 표(이미지 ·
옵션 · 품목코드 · 자체코드 · 추가금액 · 진열 · 판매)로 그린다. "옵션별 상품코드/추가금액/진열" 은 품목 옵션 · 품목코드 · 자체코드 · 추가금액 · 진열 · 판매)로 그린다. "옵션별 상품코드/추가금액/진열" 은 품목