feat(cupang): 등록된 제품 수정 팝업

- 동작 열에 [수정] 버튼 → 제품명/제품코드/쿠팡상품코드 수정 팝업
- POST /cupang/api/products/{id}/edit (JSON), 제품코드 중복은 400
- 제품코드 변경 시 cupang_box_rules 의 product_code/스냅샷도 함께 이동
  (같은 코드 규칙이 이미 있으면 이동하지 않음). 출고 라인은 과거 기록이라 유지

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 12:00:33 +09:00
parent 9ddced268f
commit 7174edd702
3 changed files with 178 additions and 0 deletions
+67
View File
@@ -338,6 +338,73 @@ class CupangDBStore:
if cur.rowcount == 0:
raise KeyError(product_id)
def update_product(
self,
*,
product_id: int,
product_name: str,
product_code: str,
coupang_item_code: str = "",
) -> dict[str, Any]:
"""등록된 제품 수정(제품명/제품코드/쿠팡상품코드).
제품코드가 바뀌면 같은 코드로 연결돼 있던 박스 규칙(cupang_box_rules)의
product_code 도 함께 옮긴다(출고 라인은 과거 기록이라 스냅샷 유지).
"""
name = (product_name or "").strip()
code = (product_code or "").strip()
cic = (coupang_item_code or "").strip()
if not name or not code:
raise ValueError("제품코드와 제품명 모두 필요합니다.")
with self._pool.connection() as conn:
with conn.transaction():
old = conn.execute(
"SELECT * FROM cupang_products WHERE id = %s FOR UPDATE",
(product_id,),
).fetchone()
if old is None:
raise KeyError(product_id)
old_code = (old["product_code"] or "").strip()
if code != old_code:
dup = conn.execute(
"SELECT 1 FROM cupang_products "
"WHERE product_code = %s AND id <> %s",
(code, product_id),
).fetchone()
if dup:
raise ValueError(f"제품코드 {code} 는 이미 등록돼 있습니다.")
row = conn.execute(
"""
UPDATE cupang_products
SET product_name = %s,
product_code = %s,
coupang_item_code = %s
WHERE id = %s
RETURNING *
""",
(name, code, cic, product_id),
).fetchone()
if code != old_code:
# 새 코드로 된 규칙이 이미 있으면 옮기지 않는다(중복 방지).
exists_new = conn.execute(
"SELECT 1 FROM cupang_box_rules WHERE product_code = %s",
(code,),
).fetchone()
if not exists_new:
conn.execute(
"UPDATE cupang_box_rules "
"SET product_code = %s, product_name_snapshot = %s "
"WHERE product_code = %s",
(code, name, old_code),
)
else:
conn.execute(
"UPDATE cupang_box_rules SET product_name_snapshot = %s "
"WHERE product_code = %s",
(name, code),
)
return self._product_serialize(row)
def toggle_product_active(self, *, product_id: int) -> dict[str, Any]:
"""활성 ↔ 비활성 뒤집기. 갱신된 행을 반환."""
with self._pool.connection() as conn:
+25
View File
@@ -917,6 +917,31 @@ async def product_set_active(
return RedirectResponse(url="/cupang/products", status_code=303)
@router.post("/api/products/{product_id:int}/edit")
async def product_edit(
request: Request,
product_id: int,
payload: dict[str, Any] = Body(...),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
"""등록된 제품 수정. body: {product_name, product_code, coupang_item_code}."""
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
product = store.update_product(
product_id=product_id,
product_name=str(payload.get("product_name") or ""),
product_code=str(payload.get("product_code") or ""),
coupang_item_code=str(payload.get("coupang_item_code") or ""),
)
except KeyError:
raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"ok": True, "product": product})
@router.post("/api/products/{product_id:int}/toggle")
async def product_toggle_active(
request: Request,
@@ -81,6 +81,8 @@
</td>
<td>
<div class="cpg-row-actions">
<button type="button" class="erp-btn erp-btn-outline cpg-prod-edit"
data-edit-id="{{ p.id }}">수정</button>
<form method="post" action="/cupang/products/{{ p.id }}/delete" class="cpg-inline-form"
onsubmit="return confirm('완전 삭제합니다. 계속할까요?');">
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
@@ -137,6 +139,90 @@
})();
</script>
<!-- 등록된 제품 수정 팝업 -->
<div class="cpg-modal" id="cpg-edit-dlg" hidden>
<div class="cpg-modal-back" data-edit-close></div>
<div class="cpg-modal-box" role="dialog" aria-modal="true" aria-labelledby="cpg-edit-title">
<h3 id="cpg-edit-title">제품 수정</h3>
<label class="erp-field"><span>제품명 *</span>
<input class="erp-input" type="text" id="cpg-edit-name" /></label>
<label class="erp-field"><span>제품코드 *</span>
<input class="erp-input" type="text" id="cpg-edit-code" /></label>
<label class="erp-field"><span>쿠팡상품코드</span>
<input class="erp-input" type="text" id="cpg-edit-cpcode" placeholder="비우면 지워집니다" /></label>
<p class="erp-muted" id="cpg-edit-msg" style="margin:0;font-size:12px;"></p>
<div class="cpg-dlg-actions erp-page-actions">
<button type="button" class="erp-btn erp-btn-outline" data-edit-close>취소</button>
<button type="button" class="erp-btn erp-btn-primary" id="cpg-edit-save">저장</button>
</div>
</div>
</div>
<script>
// 등록된 제품 수정 — [수정] 클릭 → 팝업에서 제품명/제품코드/쿠팡상품코드 변경.
(function () {
var tbody = document.getElementById("cpg-prod-tbody");
var dlg = document.getElementById("cpg-edit-dlg");
if (!tbody || !dlg) return;
var elName = document.getElementById("cpg-edit-name");
var elCode = document.getElementById("cpg-edit-code");
var elCp = document.getElementById("cpg-edit-cpcode");
var elMsg = document.getElementById("cpg-edit-msg");
var saveBtn = document.getElementById("cpg-edit-save");
var editingId = null;
function close() { dlg.hidden = true; editingId = null; saveBtn.disabled = false; }
tbody.addEventListener("click", function (e) {
var btn = e.target.closest(".cpg-prod-edit");
if (!btn) return;
var row = btn.closest("tr");
editingId = btn.getAttribute("data-edit-id");
elName.value = row.getAttribute("data-name") || "";
elCode.value = row.getAttribute("data-code") || "";
elCp.value = row.getAttribute("data-cpcode") || "";
elMsg.textContent = "";
dlg.hidden = false;
elName.focus();
});
Array.prototype.forEach.call(dlg.querySelectorAll("[data-edit-close]"), function (el) {
el.addEventListener("click", close);
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !dlg.hidden) close();
});
saveBtn.addEventListener("click", function () {
if (!editingId) return;
var body = {
product_name: (elName.value || "").trim(),
product_code: (elCode.value || "").trim(),
coupang_item_code: (elCp.value || "").trim()
};
if (!body.product_name || !body.product_code) {
elMsg.textContent = "제품명과 제품코드는 필수입니다."; return;
}
saveBtn.disabled = true;
elMsg.textContent = "저장 중…";
fetch("/cupang/api/products/" + editingId + "/edit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
}).then(function (r) {
return r.json().then(function (d) {
if (!r.ok) throw new Error((d && d.detail) || "저장 실패");
return d;
});
}).then(function () { location.reload(); })
.catch(function (err) {
saveBtn.disabled = false;
elMsg.textContent = err.message || "저장 실패";
});
});
})();
</script>
<script>
// 상태 배지 클릭 → 활성/비활성 토글 (페이지 새로고침 없음).
(function () {