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:
@@ -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:
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user