Files
dbx-main/app/integrations/cafe24/products.py
T
king 119a128ee0 feat(cafe24): 상품관리를 좌우 2분할로 — 목록(좁게) | 상세페이지 편집(넓게)
왼쪽에서 상품을 클릭하면 오른쪽에 편집기가 바로 열린다. 목록을 다시 받지 않고
오른쪽 조각만 교체한다(GET /products/{no}/pane → JS 삽입). 목록까지 다시 그리면
클릭마다 카페24 호출이 2회 더 늘어나기 때문이다. JS 가 실패하거나 없으면 각 행의
링크(/cafe24/?selected=)로 그대로 동작한다.

목록은 페이지를 없애고 전체를 한 번에 받는다(list_all_products, 1회 100개·상한
1000개). 필터·정렬을 한 페이지에만 적용하면 다음 페이지에 있는 상품이 빠져
"진열중만 보기" 가 거짓이 된다. 현재 87개라 1회 호출로 끝난다.

컬럼은 요청대로 번호·상품명·진열·판매·수정 5개다. 좁은 칸에 맞춰 진열/판매는
배지 대신 점, 수정일은 월-일만 표시하고 전체 값은 title 로 둔다. 긴 상품명은
2줄로 제한해 행 높이를 고르게 유지한다(전체 이름은 title·편집기 제목에서 확인).

진열중/판매중 체크박스는 중복 선택이 되며 둘 다 켜면 AND 다. 문서에 없는 API
필터 파라미터에 기대지 않고 받아온 뒤 파이썬에서 걸러낸다. 제목행 클릭은
오름↔내림 토글이며 한글 정렬은 localeCompare(ko) 를 쓴다.

편집 영역을 넓게 쓰려고 이 화면에서만 .erp-page 의 max-width 를 풀었다. 이때
box-sizing:border-box 를 함께 줘야 한다 — width:100% + padding:24px 이라
max-width 만 풀면 문서 전체에 가로 스크롤이 생긴다(측정으로 확인 후 수정).

편집 중 다른 상품을 클릭하거나 페이지를 벗어나면 저장 안 됨 경고를 띄운다.

옛 단독 화면(product.html)은 제거하고 /products/{no} 는 2분할 화면으로
리다이렉트한다. 편집기 조각을 두 곳에서 함께 쓰도록 _editor.html 로 분리했다.

검증: 유닛테스트 33개 통과(신규 3개 — 전체 조회의 페이지 순회·상한 처리·1회
종료). 상한 처리는 테스트가 잡아서 고쳤다(요청한 만큼 받았는지로 판정). 가짜
데이터로 렌더해 브라우저에서 실측: 왼쪽 360px·오른쪽 940px, 각 칸 독립 스크롤,
분할 영역이 화면 높이에 맞고, 가로 스크롤 없음, 정렬 오름/내림 동작 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:34:52 +09:00

217 lines
7.8 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
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 build_update_payload(
*,
description: str,
mobile_description: str | None = None,
shop_no: int | None = None,
) -> dict[str, Any]:
"""상품 수정 PUT body. 준 필드만 바뀌고 나머지는 유지된다(부분 수정).
`mobile_description=None` 이면 모바일 필드를 건드리지 않는다. PC/모바일
미분리(separated_mobile=False) 상품은 호출부가 같은 HTML 을 두 번 넘겨
두 필드를 함께 맞춘다.
"""
request: dict[str, Any] = {"description": description}
if mobile_description is not None:
request["mobile_description"] = mobile_description
payload: dict[str, Any] = {"request": request}
if shop_no:
payload["shop_no"] = int(shop_no)
return payload
def update_descriptions(
client: Cafe24Client,
product_no: int,
*,
description: str,
mobile_description: str | None = None,
shop_no: int | None = None,
) -> dict[str, Any]:
"""상세설명 교체. 성공하면 카페24가 돌려준 상품 dict.
실패는 Cafe24ApiError/Cafe24AuthError 로 올라오므로 호출부는 예외가 없을
때만 성공으로 처리하면 된다.
⚠️ 쓰기 직전 항상 카페24 현재 HTML 을 다시 읽어 BACKUP revision 을 남길
것(`docs/CAFE24_MODULE.md` 보안 규칙). 이 함수는 백업을 하지 않는다.
"""
no = int(product_no)
payload = client.put(
f"/admin/products/{no}",
json=build_update_payload(
description=description,
mobile_description=mobile_description,
shop_no=shop_no,
),
product_no=no,
)
product = payload.get("product")
return product if isinstance(product, dict) else payload
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")),
}