85c7333e72
- 추가(「+ 행 추가」/불러오기의 새 이름): 저장 시 옵션값을 끝에 덧붙여
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>
477 lines
20 KiB
Python
477 lines
20 KiB
Python
"""카페24 상품 엔드포인트 래퍼.
|
|
|
|
전송/재시도/인증은 Cafe24Client 가 담당하고, 여기서는 경로와 payload 모양만
|
|
안다. 향후 주문관리는 같은 클라이언트로 `orders.py` 를 추가하면 된다.
|
|
|
|
상세설명은 **별도 리소스가 아니다.** 실제 쇼핑몰(miraskitchen)에 확인한 결과
|
|
`/admin/products/{no}/description` 은 존재하지 않는다(`No API found.`).
|
|
상세설명은 상품 리소스의 필드로 읽고 쓴다.
|
|
|
|
GET /admin/products/{no} → description · mobile_description ·
|
|
separated_mobile_description
|
|
PUT /admin/products/{no} → {"request": {"description": ...}}
|
|
|
|
목록 API(`/admin/products`) 응답에는 description 이 **없다**. 그래서 상세설명은
|
|
상품 1건씩 조회해야 한다(목록 화면에서 미리보기를 뿌리지 않는 이유).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from .client import Cafe24Client
|
|
|
|
# 카페24 상품 목록 API 의 1회 최대 조회 수
|
|
PAGE_LIMIT = 100
|
|
|
|
|
|
def _flag(value: Any, *, default: bool = True) -> bool:
|
|
"""카페24는 boolean 을 'T'/'F' 문자열로 준다."""
|
|
if isinstance(value, bool):
|
|
return value
|
|
text = str(value or "").strip().upper()
|
|
if text in ("T", "TRUE", "Y", "1"):
|
|
return True
|
|
if text in ("F", "FALSE", "N", "0"):
|
|
return False
|
|
return default
|
|
|
|
|
|
def count_products(client: Cafe24Client, *, product_name: str = "") -> int:
|
|
params: dict[str, Any] = {}
|
|
if product_name:
|
|
params["product_name"] = product_name
|
|
payload = client.get("/admin/products/count", params=params)
|
|
try:
|
|
return int(payload.get("count") or 0)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def list_products(
|
|
client: Cafe24Client,
|
|
*,
|
|
limit: int = PAGE_LIMIT,
|
|
offset: int = 0,
|
|
product_name: str = "",
|
|
product_no: int | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""상품 목록 1페이지. 검색어가 있으면 상품명 부분일치로 조회한다."""
|
|
params: dict[str, Any] = {
|
|
"limit": max(1, min(int(limit), PAGE_LIMIT)),
|
|
"offset": max(0, int(offset)),
|
|
}
|
|
if product_name:
|
|
params["product_name"] = product_name
|
|
if product_no:
|
|
params["product_no"] = int(product_no)
|
|
payload = client.get("/admin/products", params=params)
|
|
products = payload.get("products")
|
|
return products if isinstance(products, list) else []
|
|
|
|
|
|
def list_all_products(
|
|
client: Cafe24Client,
|
|
*,
|
|
product_name: str = "",
|
|
max_items: int = 1000,
|
|
) -> tuple[list[dict[str, Any]], bool]:
|
|
"""전체 상품을 페이지를 넘겨가며 모두 가져온다.
|
|
|
|
2분할 화면의 왼쪽 목록은 페이지 없이 한 번에 보여주고 필터·정렬을 브라우저에서
|
|
처리한다. 그래야 "진열중만" 같은 필터가 전체 기준으로 정확해진다
|
|
(한 페이지만 받아 걸러내면 다음 페이지의 해당 상품이 빠진다).
|
|
|
|
반환: (상품 목록, 상한에 걸려 잘렸는지)
|
|
상품이 max_items 를 넘으면 거기서 멈춘다 — 무한 호출로 API 제한에 걸리는
|
|
것을 막기 위한 안전장치다(현재 쇼핑몰 87개, 1회 100개 조회).
|
|
"""
|
|
collected: list[dict[str, Any]] = []
|
|
while len(collected) < max_items:
|
|
want = min(PAGE_LIMIT, max_items - len(collected))
|
|
batch = list_products(
|
|
client,
|
|
limit=want,
|
|
offset=len(collected),
|
|
product_name=product_name,
|
|
)
|
|
collected.extend(batch)
|
|
if len(batch) < want:
|
|
return collected, False # 요청한 만큼 못 받았다 = 마지막 페이지
|
|
if len(collected) >= max_items:
|
|
return collected, True # 상한에서 멈췄다 — 뒤에 더 있을 수 있다
|
|
return collected, False
|
|
|
|
|
|
def get_product(client: Cafe24Client, product_no: int) -> dict[str, Any]:
|
|
"""상품 1건 상세. 이 응답에 상세설명 필드까지 들어 있다."""
|
|
no = int(product_no)
|
|
payload = client.get(f"/admin/products/{no}", product_no=no)
|
|
product = payload.get("product")
|
|
return product if isinstance(product, dict) else {}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Descriptions:
|
|
"""상품 1건의 상세설명 묶음. 카페24가 언제나 source of truth 다."""
|
|
|
|
product_no: int
|
|
product_name: str
|
|
description: str
|
|
mobile_description: str
|
|
# separated_mobile_description = 'T' 면 PC/모바일 상세설명을 따로 쓴다.
|
|
# 'F' 면 모바일도 PC 값을 쓰므로 수정 시 두 필드를 함께 맞춰야 한다.
|
|
separated_mobile: bool
|
|
|
|
@property
|
|
def mobile_differs(self) -> bool:
|
|
return self.mobile_description != self.description
|
|
|
|
|
|
def descriptions_from_product(raw: dict[str, Any]) -> Descriptions:
|
|
"""`get_product` 응답 dict → Descriptions."""
|
|
try:
|
|
product_no = int(raw.get("product_no") or 0)
|
|
except (TypeError, ValueError):
|
|
product_no = 0
|
|
return Descriptions(
|
|
product_no=product_no,
|
|
product_name=str(raw.get("product_name") or ""),
|
|
description=str(raw.get("description") or ""),
|
|
mobile_description=str(raw.get("mobile_description") or ""),
|
|
separated_mobile=_flag(raw.get("separated_mobile_description"), default=False),
|
|
)
|
|
|
|
|
|
def fetch_descriptions(client: Cafe24Client, product_no: int) -> Descriptions:
|
|
"""상품의 현재 상세설명. 로컬 DB 의 마지막 버전을 현재값으로 가정하지 않는다."""
|
|
return descriptions_from_product(get_product(client, product_no))
|
|
|
|
|
|
def wait_for_description(
|
|
client: Cafe24Client,
|
|
product_no: int,
|
|
expected: str,
|
|
*,
|
|
attempts: int = 3,
|
|
delay: float = 0.8,
|
|
) -> bool:
|
|
"""PUT 직후 카페24 관리자 API 가 새 값을 돌려줄 때까지 짧게 재확인한다.
|
|
|
|
실물에서 관찰된 지연: PUT 이 성공하고 쇼핑몰 화면(고객이 보는 상세페이지)에는
|
|
바로 반영되는데도, 관리자 API(`GET /admin/products/{no}`)는 몇 초간 직전 값을
|
|
돌려줄 때가 있다. 그 상태에서 다른 상품을 봤다가 다시 돌아오면 우리 편집기가
|
|
"적용 안 된 것"처럼 보인다 — 우리 쪽 캐시 문제가 아니라 카페24 쪽 읽기 지연이다.
|
|
적용 직후 여기서 짧게 흡수해, 화면에 돌아왔을 때는 이미 새 값이 보이게 한다.
|
|
실패해도 PUT 자체는 이미 성공했으므로 예외를 던지지 않는다.
|
|
"""
|
|
for _ in range(max(1, attempts)):
|
|
time.sleep(delay)
|
|
try:
|
|
current = fetch_descriptions(client, product_no)
|
|
except Exception: # noqa: BLE001 — 확인 실패는 무시(적용 자체는 이미 성공)
|
|
return False
|
|
if current.description == expected:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _flag_value(flag: bool) -> str:
|
|
"""카페24는 boolean 을 'T'/'F' 문자열로 받는다."""
|
|
return "T" if flag else "F"
|
|
|
|
|
|
def build_update_payload(
|
|
*,
|
|
description: str | None = None,
|
|
mobile_description: str | None = None,
|
|
separated_mobile_description: str | None = None,
|
|
product_name: str | None = None,
|
|
display: bool | None = None,
|
|
selling: bool | None = None,
|
|
shop_no: int | None = None,
|
|
price: str | None = None,
|
|
supply_price: str | None = None,
|
|
retail_price: str | None = None,
|
|
detail_image: str | None = None,
|
|
image_upload_type: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""상품 수정 PUT body. 준 필드만 바뀌고 나머지는 유지된다(부분 수정).
|
|
|
|
`None` 인 항목은 payload 에 넣지 않는다 = 그 필드를 건드리지 않는다.
|
|
예약에서 "진열만 켜기"처럼 상세설명 없이 상태만 바꾸는 경우가 있으므로
|
|
description 도 생략할 수 있다.
|
|
|
|
⚠️ `mobile_description` 을 명시적으로 보내면 카페24가 `separated_mobile_description`
|
|
을 'T'(관리자 화면 "직접 등록")로 바꿔버린다(실물 확인). "PC 상세설명과 동일"을
|
|
유지하려면 `mobile_description` 은 보내지 말고 `separated_mobile_description="F"`
|
|
만 지정할 것 — `update_descriptions` 가 이 방식을 쓴다.
|
|
"""
|
|
request: dict[str, Any] = {}
|
|
if description is not None:
|
|
request["description"] = description
|
|
if product_name is not None:
|
|
request["product_name"] = product_name
|
|
if mobile_description is not None:
|
|
request["mobile_description"] = mobile_description
|
|
if separated_mobile_description is not None:
|
|
request["separated_mobile_description"] = separated_mobile_description
|
|
if display is not None:
|
|
request["display"] = _flag_value(display)
|
|
if selling is not None:
|
|
request["selling"] = _flag_value(selling)
|
|
# 가격은 카페24 예제 형식('11000.00') 문자열 그대로 보낸다.
|
|
if price is not None:
|
|
request["price"] = price
|
|
if supply_price is not None:
|
|
request["supply_price"] = supply_price
|
|
if retail_price is not None:
|
|
request["retail_price"] = retail_price
|
|
# 대표 이미지: /admin/products/images 로 먼저 올린 경로를 detail_image 에 넣고
|
|
# image_upload_type="A"(대표이미지등록) 로 목록/작은목록/축소 이미지를 카페24가
|
|
# 리사이징하게 한다. (문서: A 대표이미지등록 / B 개별이미지등록 / C 웹FTP)
|
|
if detail_image is not None:
|
|
request["detail_image"] = detail_image
|
|
request["image_upload_type"] = image_upload_type or "A"
|
|
payload: dict[str, Any] = {"request": request}
|
|
if shop_no:
|
|
payload["shop_no"] = int(shop_no)
|
|
return payload
|
|
|
|
|
|
def update_product(
|
|
client: Cafe24Client,
|
|
product_no: int,
|
|
*,
|
|
description: str | None = None,
|
|
mobile_description: str | None = None,
|
|
separated_mobile_description: str | None = None,
|
|
product_name: str | None = None,
|
|
display: bool | None = None,
|
|
selling: bool | None = None,
|
|
shop_no: int | None = None,
|
|
price: str | None = None,
|
|
supply_price: str | None = None,
|
|
retail_price: str | None = None,
|
|
detail_image: str | None = None,
|
|
image_upload_type: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""상품 부분 수정. 상세설명·상품명·가격·대표이미지·진열·판매를 한 번의 호출로.
|
|
|
|
바꿀 것이 하나도 없으면 호출하지 않고 빈 dict 를 돌려준다.
|
|
응답의 `product` dict 는 **쓰기 직후의 실제 값**이다 — GET 이 한동안 예전 값을
|
|
돌려주는 것과 달리 PUT 응답은 즉시 새 값을 담으므로, 호출부는 이것을 스냅샷으로
|
|
남겨 화면을 맞춘다(`store.product_snapshot`).
|
|
|
|
⚠️ 상세설명을 바꿀 때는 쓰기 직전 카페24 현재 HTML 을 다시 읽어 BACKUP
|
|
revision 을 남길 것(`docs/CAFE24_MODULE.md` 규칙). 이 함수는 백업하지 않는다.
|
|
"""
|
|
payload = build_update_payload(
|
|
description=description,
|
|
mobile_description=mobile_description,
|
|
separated_mobile_description=separated_mobile_description,
|
|
product_name=product_name,
|
|
display=display,
|
|
selling=selling,
|
|
shop_no=shop_no,
|
|
price=price,
|
|
supply_price=supply_price,
|
|
retail_price=retail_price,
|
|
detail_image=detail_image,
|
|
image_upload_type=image_upload_type,
|
|
)
|
|
if not payload["request"]:
|
|
return {}
|
|
no = int(product_no)
|
|
response = client.put(f"/admin/products/{no}", json=payload, product_no=no)
|
|
product = response.get("product")
|
|
return product if isinstance(product, dict) else response
|
|
|
|
|
|
def update_descriptions(
|
|
client: Cafe24Client,
|
|
product_no: int,
|
|
*,
|
|
description: str,
|
|
shop_no: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""상세설명 교체 + "PC 상세설명과 동일" 강제.
|
|
|
|
`mobile_description` 필드는 보내지 않는다 — 보내는 순간 카페24 관리자
|
|
화면의 모바일 상세설명 설정이 "직접 등록"으로 바뀌어버리기 때문이다(실물
|
|
확인). 대신 `separated_mobile_description="F"` 만 지정하면 카페24가 모바일
|
|
값을 PC 와 자동으로 맞춰주면서 설정도 "PC 상세설명과 동일하게 사용"으로
|
|
유지된다.
|
|
"""
|
|
return update_product(
|
|
client,
|
|
product_no,
|
|
description=description,
|
|
separated_mobile_description="F",
|
|
shop_no=shop_no,
|
|
)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# 이미지 업로드 — POST /admin/products/images
|
|
# 문서: base64 인코딩 이미지, 1건 10MB, 1호출 30MB, 1회 20장.
|
|
# 응답 {"images":[{"path":"https://{domain}/web/upload/NNEditor/…"}]} 의 path 를
|
|
# 상품 detail_image / 옵션 option_image_file 등에 그대로 넣는다.
|
|
# ════════════════════════════════════════════════════════════
|
|
IMAGE_MAX_BYTES = 10 * 1024 * 1024
|
|
UPLOAD_TIMEOUT = 120.0
|
|
|
|
|
|
def upload_images(client: Cafe24Client, images_b64: list[str]) -> list[str]:
|
|
"""base64 문자열 목록 → 업로드된 경로 목록(입력 순서 유지)."""
|
|
if not images_b64:
|
|
return []
|
|
payload = {"requests": [{"image": b64} for b64 in images_b64[:20]]}
|
|
response = client.post("/admin/products/images", json=payload, timeout=UPLOAD_TIMEOUT)
|
|
images = response.get("images")
|
|
if not isinstance(images, list):
|
|
return []
|
|
return [str(item.get("path") or "") for item in images if isinstance(item, dict)]
|
|
|
|
|
|
def upload_image_bytes(client: Cafe24Client, data: bytes) -> str:
|
|
"""이미지 1장(바이트) 업로드 → 경로. 비어 있거나 응답이 이상하면 빈 문자열."""
|
|
import base64 # noqa: WPS433
|
|
|
|
if not data:
|
|
return ""
|
|
paths = upload_images(client, [base64.b64encode(data).decode("ascii")])
|
|
return paths[0] if paths else ""
|
|
|
|
|
|
def set_main_image(client: Cafe24Client, product_no: int, image_path: str) -> dict[str, Any]:
|
|
"""대표이미지 교체 — 목록/작은목록/축소 이미지는 카페24가 리사이징(A 타입)."""
|
|
return update_product(client, product_no, detail_image=image_path, image_upload_type="A")
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# 옵션 — /admin/products/{no}/options
|
|
# GET → {"option": {has_option, option_type, option_list_type, options:[...]}}
|
|
# POST → {"request": {has_option:"T", option_type:"T", options:[...]}} (품목 자동 생성)
|
|
# PUT → {"request": {original_options:[...], options:[...]}} (이름/값/이미지만 수정)
|
|
# DELETE → 옵션 사용안함 + 품목 전부 삭제(주의)
|
|
# ════════════════════════════════════════════════════════════
|
|
def get_options(client: Cafe24Client, product_no: int) -> dict[str, Any]:
|
|
no = int(product_no)
|
|
payload = client.get(f"/admin/products/{no}/options", product_no=no)
|
|
option = payload.get("option")
|
|
return option if isinstance(option, dict) else {}
|
|
|
|
|
|
def create_options(client: Cafe24Client, product_no: int, request: dict[str, Any]) -> dict[str, Any]:
|
|
no = int(product_no)
|
|
payload = client.post(
|
|
f"/admin/products/{no}/options", json={"shop_no": 1, "request": request}, product_no=no
|
|
)
|
|
option = payload.get("option")
|
|
return option if isinstance(option, dict) else payload
|
|
|
|
|
|
def update_options(client: Cafe24Client, product_no: int, request: dict[str, Any]) -> dict[str, Any]:
|
|
no = int(product_no)
|
|
payload = client.put(
|
|
f"/admin/products/{no}/options", json={"shop_no": 1, "request": request}, product_no=no
|
|
)
|
|
option = payload.get("option")
|
|
return option if isinstance(option, dict) else payload
|
|
|
|
|
|
def delete_options(client: Cafe24Client, product_no: int) -> dict[str, Any]:
|
|
no = int(product_no)
|
|
return client.delete(f"/admin/products/{no}/options", product_no=no)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# 품목(variants) — /admin/products/{no}/variants
|
|
# GET → {"variants":[{variant_code, options:[{name,value}], custom_variant_code,
|
|
# display, selling, additional_amount, quantity, image?…}]}
|
|
# PUT (여러 건) → {"shop_no":1, "requests":[{variant_code, display, selling,
|
|
# custom_variant_code, additional_amount, …}]}
|
|
# ════════════════════════════════════════════════════════════
|
|
def list_variants(client: Cafe24Client, product_no: int) -> list[dict[str, Any]]:
|
|
no = int(product_no)
|
|
payload = client.get(f"/admin/products/{no}/variants", product_no=no)
|
|
variants = payload.get("variants")
|
|
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,
|
|
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(
|
|
client: Cafe24Client, product_no: int, requests: list[dict[str, Any]]
|
|
) -> list[dict[str, Any]]:
|
|
"""여러 품목 부분 수정. 100건씩 나눠 보낸다(문서상 1회 100건 제한)."""
|
|
no = int(product_no)
|
|
out: list[dict[str, Any]] = []
|
|
for start in range(0, len(requests), 100):
|
|
chunk = requests[start : start + 100]
|
|
if not chunk:
|
|
continue
|
|
payload = client.put(
|
|
f"/admin/products/{no}/variants", json={"shop_no": 1, "requests": chunk}, product_no=no
|
|
)
|
|
result = payload.get("variants") if isinstance(payload, dict) else None
|
|
if isinstance(result, list):
|
|
out.extend(result)
|
|
elif isinstance(result, dict):
|
|
out.append(result)
|
|
elif isinstance(payload.get("variant"), dict):
|
|
out.append(payload["variant"])
|
|
return out
|
|
|
|
|
|
def normalize_product(raw: dict[str, Any]) -> dict[str, Any]:
|
|
"""카페24 상품 dict → 캐시 테이블(cafe24_products) 컬럼 모양으로 정규화."""
|
|
try:
|
|
product_no = int(raw.get("product_no") or 0)
|
|
except (TypeError, ValueError):
|
|
product_no = 0
|
|
|
|
return {
|
|
"product_no": product_no,
|
|
"product_code": str(raw.get("product_code") or ""),
|
|
"product_name": str(raw.get("product_name") or ""),
|
|
"display": _flag(raw.get("display")),
|
|
"selling": _flag(raw.get("selling")),
|
|
}
|