feat(cafe24): 읽기 지연 보정("마지막 쓰기가 권위") + 상품 정보 패널
증상: 상세페이지를 적용해도 편집기에 수정 전 소스가 보이고 한참 뒤에야 반영됨. 원인은 우리 캐시가 아니라(전부 no-store) 카페24 관리자 API 가 PUT 뒤 한동안 GET 에서 예전 값을 돌려주는 읽기 지연. 예전 코드는 2.4초만 기다린 뒤 GET 값을 그대로 믿어 예전 소스 표시·지문 충돌 오판·예전 값 백업이 생겼다. - 상세설명: 쓰기 성공 시 MANUAL/SCHEDULED revision 을 기준으로, 카페24 값이 유예시간 안의 revision 중 하나와 같으면 지연(pending)으로 보고 마지막 쓰기를 표시·지문 기준으로 쓴다. 모르는 값이면 외부 변경(external). store.resolve_description / db.revision_digests(md5) / 배너 2종. - 적용(apply)은 유효 현재값으로 BACKUP·지문 대조·변경없음 판정. 재조회 확인 결과는 감사로그에만 남긴다. - 스칼라(상품명·가격·이미지·진열/판매): PUT 응답을 cafe24_products. last_write_snapshot(JSONB, 마이그레이션 004)에 남기고 GET 의 updated_date 가 그보다 이전이면 스냅샷으로 덮어씀. 옵션/품목도 섹션별 스냅샷. - 3분할 화면: 목록 | 편집기 | 상품 정보 패널(_side.html, /pane 이 두 조각을 한 응답으로). routes_product_info.py JSON API — 상품명/판매가/공급가/ 소비자가, 대표이미지 업로드(POST /admin/products/images → PUT detail_image + image_upload_type=A), 옵션 생성/이름·썸네일·표시방식 수정/삭제, 품목 자체코드·추가금액·진열·판매 일괄 수정. 화면은 PUT 응답으로 그린다. - client.delete/timeout, products.upload_images·options·variants 래퍼. - 유닛테스트 21건 추가(88 통과), 문서(CAFE24_MODULE 3-3/3-4, DATABASES, .env.example CAFE24_READ_LAG_GRACE_MIN) 갱신. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -105,6 +105,12 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
|
|||||||
# 미설정 시 카페24 기본 도메인(https://<mall_id>.cafe24.com)으로 대체된다.
|
# 미설정 시 카페24 기본 도메인(https://<mall_id>.cafe24.com)으로 대체된다.
|
||||||
# CAFE24_SHOP_URL=https://www.miras.co.kr
|
# CAFE24_SHOP_URL=https://www.miras.co.kr
|
||||||
#
|
#
|
||||||
|
# 카페24 읽기 지연 유예시간(분). 카페24 관리자 API 는 PUT 뒤 한동안 GET 에서 예전
|
||||||
|
# 값을 돌려준다. 이 시간 안에 우리가 쓴 값이 있으면, GET 이 그 이전 값을 돌려줘도
|
||||||
|
# 마지막 쓰기(revision·스냅샷)를 화면·적용 기준으로 삼는다. 지나면 카페24 값을 믿는다.
|
||||||
|
# 미설정 시 360(6시간).
|
||||||
|
# CAFE24_READ_LAG_GRACE_MIN=360
|
||||||
|
#
|
||||||
# access/refresh token 을 DB 에 Fernet 암호화해서 저장할 때 쓰는 키.
|
# access/refresh token 을 DB 에 Fernet 암호화해서 저장할 때 쓰는 키.
|
||||||
# openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어
|
# openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어
|
||||||
# 카페24 재연결(재인증)이 필요하다.
|
# 카페24 재연결(재인증)이 필요하다.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
|
|||||||
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
|
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
|
||||||
- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia`
|
- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia`
|
||||||
- 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1상자=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1상자 묶음 기준 Package ID > Tracking ID > Order ID, 같은 상자 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 상자 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md`
|
- 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1상자=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1상자 묶음 기준 Package ID > Tracking ID > Order ID, 같은 상자 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 상자 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md`
|
||||||
- 카페24 상품관리 (`app/modules/cafe24/`, `cafe24_db`) — 카페24 관리자에 들어가지 않고 상품 상세페이지(description HTML) 조회·편집·즉시적용·예약적용·자동복원·버전 롤백·일괄수정. 카페24 OAuth/API 클라이언트는 향후 주문관리와 공유하기 위해 **공통 계층 `app/integrations/cafe24/`** 에 둔다 — 라우터에서 `httpx`/`requests` 직접 호출 금지. 토큰은 Fernet 암호화 저장(`CAFE24_TOKEN_SECRET`), 로그/화면에 토큰·시크릿 절대 미출력. 쓰기 직전 항상 카페24 현재 HTML 을 다시 읽어 `BACKUP` revision 생성(로컬 값을 현재값으로 가정 금지). 예약은 DB 저장 + 별도 worker(`app/modules/cafe24/worker.py`, compose 서비스 `dbx-cafe24-worker`)가 처리 — 웹 프로세스에서 대기하지 않는다. 권한키 `cafe24`(연결/해제는 admin 전용). 상세는 `docs/CAFE24_MODULE.md`
|
- 카페24 상품관리 (`app/modules/cafe24/`, `cafe24_db`) — 카페24 관리자에 들어가지 않고 상품 상세페이지(description HTML) 조회·편집·즉시적용·예약적용·버전 이력. 3분할 화면(목록 | HTML 편집기 | 상품 정보 패널). 정보 패널(`routes_product_info.py`, `_side.html`)에서 상품명·판매가·공급가·소비자가, 대표이미지(업로드 → `POST /admin/products/images` → PUT `detail_image`+`image_upload_type=A`), 옵션(생성/이름·썸네일·표시방식 수정/삭제)·품목(자체코드·추가금액·진열·판매)을 수정. 카페24 OAuth/API 클라이언트는 향후 주문관리와 공유하기 위해 **공통 계층 `app/integrations/cafe24/`** 에 둔다 — 라우터에서 `httpx`/`requests` 직접 호출 금지. 토큰은 Fernet 암호화 저장(`CAFE24_TOKEN_SECRET`), 로그/화면에 토큰·시크릿 절대 미출력. 쓰기 직전 항상 카페24 현재 HTML 을 다시 읽어 `BACKUP` revision 생성(로컬 값을 현재값으로 가정 금지). **카페24 관리자 API 는 PUT 뒤 한동안 GET 에서 예전 값을 돌려준다(읽기 지연)** — 우리 캐시 문제가 아니다. 그래서 "마지막 쓰기가 권위": 상세설명은 카페24 값이 최근 revision 중 하나와 같으면 지연으로 보고 마지막 MANUAL/SCHEDULED 를 표시·지문 기준으로 쓰고(`store.resolve_description`), 스칼라(상품명·가격·이미지·진열/판매)는 PUT 응답 스냅샷(`cafe24_products.last_write_snapshot`, 마이그레이션 004)과 GET 의 `updated_date` 를 비교해 덮어씌운다(`store.overlay_recent_write`). 유예시간 `CAFE24_READ_LAG_GRACE_MIN`(기본 360분). 쓰기 후 화면은 PUT 응답으로 그리고 다시 GET 하지 않는다. 예약은 DB 저장 + 별도 worker(`app/modules/cafe24/worker.py`, compose 서비스 `dbx-cafe24-worker`)가 처리 — 웹 프로세스에서 대기하지 않는다. 권한키 `cafe24`(연결/해제는 admin 전용). 상세는 `docs/CAFE24_MODULE.md`
|
||||||
- 프로젝트 관리 (`app/modules/project/`, `project_db`) — 아사나식. 프로젝트/서브프로젝트(self-FK `parent_id`, CASCADE)·업무(`tasks`: 담당자·우선순위·시작/마감)·진행단계(`project_stages` 칸반, 생성시 기본 4단계 seed)·멤버 배정(`project_members`)·활동이력(`project_activity`). 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 버튼 토글 + 보드(드래그로 단계 이동)/리스트. 진입 권한키 `project`(관리자 페이지 토글로 직원별 부여, admin 자동). 프로젝트 생성/삭제·사용자 배정은 `is_admin` 만, 배정 멤버(또는 owner)는 서브프로젝트/업무/단계 CRUD. 멤버 배정 후보는 `project` 권한 보유 등록 사용자에서 자동 목록(`GET /project/api/assignable-users`). 업무 배정·완료 시 관리자에게 메일(`app/mail.py` stdlib smtplib, `SMTP_*`+`PROJECT_NOTIFY_EMAIL` env, 미설정 시 조용히 skip, `BackgroundTasks` 비동기). 상세는 `docs/PROJECT_MODULE.md`
|
- 프로젝트 관리 (`app/modules/project/`, `project_db`) — 아사나식. 프로젝트/서브프로젝트(self-FK `parent_id`, CASCADE)·업무(`tasks`: 담당자·우선순위·시작/마감)·진행단계(`project_stages` 칸반, 생성시 기본 4단계 seed)·멤버 배정(`project_members`)·활동이력(`project_activity`). 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 버튼 토글 + 보드(드래그로 단계 이동)/리스트. 진입 권한키 `project`(관리자 페이지 토글로 직원별 부여, admin 자동). 프로젝트 생성/삭제·사용자 배정은 `is_admin` 만, 배정 멤버(또는 owner)는 서브프로젝트/업무/단계 CRUD. 멤버 배정 후보는 `project` 권한 보유 등록 사용자에서 자동 목록(`GET /project/api/assignable-users`). 업무 배정·완료 시 관리자에게 메일(`app/mail.py` stdlib smtplib, `SMTP_*`+`PROJECT_NOTIFY_EMAIL` env, 미설정 시 조용히 skip, `BackgroundTasks` 비동기). 상세는 `docs/PROJECT_MODULE.md`
|
||||||
|
|
||||||
상세는 `docs/PROJECT_OVERVIEW.md`.
|
상세는 `docs/PROJECT_OVERVIEW.md`.
|
||||||
|
|||||||
@@ -148,8 +148,12 @@ class Cafe24Client:
|
|||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
json: dict[str, Any] | None = None,
|
json: dict[str, Any] | None = None,
|
||||||
product_no: int | None = None,
|
product_no: int | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환."""
|
"""카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환.
|
||||||
|
|
||||||
|
timeout 은 호출별 초과 지정(이미지 업로드처럼 본문이 큰 요청용). 없으면 기본값.
|
||||||
|
"""
|
||||||
if not self._config.configured:
|
if not self._config.configured:
|
||||||
raise Cafe24ConfigError(
|
raise Cafe24ConfigError(
|
||||||
"카페24 설정이 없습니다. 미설정 항목: " + ", ".join(self._config.missing)
|
"카페24 설정이 없습니다. 미설정 항목: " + ", ".join(self._config.missing)
|
||||||
@@ -167,7 +171,7 @@ class Cafe24Client:
|
|||||||
status: int | None = None
|
status: int | None = None
|
||||||
try:
|
try:
|
||||||
access_token = self._tokens.get_access_token()
|
access_token = self._tokens.get_access_token()
|
||||||
with httpx.Client(timeout=self._timeout) as client:
|
with httpx.Client(timeout=timeout or self._timeout) as client:
|
||||||
response = client.request(
|
response = client.request(
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
@@ -256,3 +260,6 @@ class Cafe24Client:
|
|||||||
|
|
||||||
def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
||||||
return self.request("POST", path, **kwargs)
|
return self.request("POST", path, **kwargs)
|
||||||
|
|
||||||
|
def delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
return self.request("DELETE", path, **kwargs)
|
||||||
|
|||||||
@@ -192,6 +192,11 @@ def build_update_payload(
|
|||||||
display: bool | None = None,
|
display: bool | None = None,
|
||||||
selling: bool | None = None,
|
selling: bool | None = None,
|
||||||
shop_no: int | 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[str, Any]:
|
||||||
"""상품 수정 PUT body. 준 필드만 바뀌고 나머지는 유지된다(부분 수정).
|
"""상품 수정 PUT body. 준 필드만 바뀌고 나머지는 유지된다(부분 수정).
|
||||||
|
|
||||||
@@ -217,6 +222,19 @@ def build_update_payload(
|
|||||||
request["display"] = _flag_value(display)
|
request["display"] = _flag_value(display)
|
||||||
if selling is not None:
|
if selling is not None:
|
||||||
request["selling"] = _flag_value(selling)
|
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}
|
payload: dict[str, Any] = {"request": request}
|
||||||
if shop_no:
|
if shop_no:
|
||||||
payload["shop_no"] = int(shop_no)
|
payload["shop_no"] = int(shop_no)
|
||||||
@@ -234,10 +252,18 @@ def update_product(
|
|||||||
display: bool | None = None,
|
display: bool | None = None,
|
||||||
selling: bool | None = None,
|
selling: bool | None = None,
|
||||||
shop_no: int | 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[str, Any]:
|
||||||
"""상품 부분 수정. 상세설명·상품명·진열·판매를 한 번의 호출로 바꿀 수 있다.
|
"""상품 부분 수정. 상세설명·상품명·가격·대표이미지·진열·판매를 한 번의 호출로.
|
||||||
|
|
||||||
바꿀 것이 하나도 없으면 호출하지 않고 빈 dict 를 돌려준다.
|
바꿀 것이 하나도 없으면 호출하지 않고 빈 dict 를 돌려준다.
|
||||||
|
응답의 `product` dict 는 **쓰기 직후의 실제 값**이다 — GET 이 한동안 예전 값을
|
||||||
|
돌려주는 것과 달리 PUT 응답은 즉시 새 값을 담으므로, 호출부는 이것을 스냅샷으로
|
||||||
|
남겨 화면을 맞춘다(`store.product_snapshot`).
|
||||||
|
|
||||||
⚠️ 상세설명을 바꿀 때는 쓰기 직전 카페24 현재 HTML 을 다시 읽어 BACKUP
|
⚠️ 상세설명을 바꿀 때는 쓰기 직전 카페24 현재 HTML 을 다시 읽어 BACKUP
|
||||||
revision 을 남길 것(`docs/CAFE24_MODULE.md` 규칙). 이 함수는 백업하지 않는다.
|
revision 을 남길 것(`docs/CAFE24_MODULE.md` 규칙). 이 함수는 백업하지 않는다.
|
||||||
@@ -250,6 +276,11 @@ def update_product(
|
|||||||
display=display,
|
display=display,
|
||||||
selling=selling,
|
selling=selling,
|
||||||
shop_no=shop_no,
|
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"]:
|
if not payload["request"]:
|
||||||
return {}
|
return {}
|
||||||
@@ -283,6 +314,117 @@ def update_descriptions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 이미지 업로드 — 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 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]:
|
def normalize_product(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""카페24 상품 dict → 캐시 테이블(cafe24_products) 컬럼 모양으로 정규화."""
|
"""카페24 상품 dict → 캐시 테이블(cafe24_products) 컬럼 모양으로 정규화."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ def get_store(request: Request) -> Any:
|
|||||||
return getattr(request.app.state, "cafe24_store", None)
|
return getattr(request.app.state, "cafe24_store", None)
|
||||||
|
|
||||||
|
|
||||||
|
def read_lag_grace_minutes() -> int:
|
||||||
|
"""카페24 읽기 지연 유예시간(분). `CAFE24_READ_LAG_GRACE_MIN`, 기본 360.
|
||||||
|
|
||||||
|
이 시간 안에 우리가 쓴 값이 있으면, 카페24 GET 이 예전 값을 돌려줘도 우리
|
||||||
|
마지막 쓰기를 화면·적용 기준으로 삼는다(store.resolve_description 참고).
|
||||||
|
"""
|
||||||
|
import os # noqa: WPS433
|
||||||
|
|
||||||
|
from . import store # noqa: WPS433
|
||||||
|
|
||||||
|
return store.parse_grace_minutes(os.getenv("CAFE24_READ_LAG_GRACE_MIN"))
|
||||||
|
|
||||||
|
|
||||||
def require_user(request: Request) -> dict[str, Any]:
|
def require_user(request: Request) -> dict[str, Any]:
|
||||||
from app.main import get_current_user_record # noqa: WPS433
|
from app.main import get_current_user_record # noqa: WPS433
|
||||||
from app.store import has_module # noqa: WPS433
|
from app.store import has_module # noqa: WPS433
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ _TOKEN_FIELDS: tuple[str, ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _flag_bool(value: Any, default: bool) -> bool:
|
||||||
|
"""카페24 'T'/'F' 또는 bool → bool."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
text = str(value or "").strip().upper()
|
||||||
|
if text in ("T", "TRUE", "1"):
|
||||||
|
return True
|
||||||
|
if text in ("F", "FALSE", "0"):
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
class TokenLock:
|
class TokenLock:
|
||||||
"""token_lock() 이 넘겨주는 핸들. 잠긴 행 조회 + 같은 트랜잭션 안 저장."""
|
"""token_lock() 이 넘겨주는 핸들. 잠긴 행 조회 + 같은 트랜잭션 안 저장."""
|
||||||
|
|
||||||
@@ -263,6 +275,118 @@ class Cafe24Store:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
return self._serialize(row)
|
return self._serialize(row)
|
||||||
|
|
||||||
|
# ── 쓰기 직후 스냅샷 (읽기 지연 보정용) ──
|
||||||
|
# 카페24 PUT 응답의 상품 값을 남겨 둔다. GET 이 아직 예전 레코드를 돌려주는
|
||||||
|
# 동안(updated_date 가 스냅샷보다 이전) 이 값으로 화면을 덮어씌운다.
|
||||||
|
# 마이그레이션 004 의 두 컬럼(last_write_snapshot, last_written_at)을 쓴다.
|
||||||
|
# 스냅샷 JSON 모양:
|
||||||
|
# {"product": {"data": {...store.SNAPSHOT_FIELDS...}, "written_at": ISO},
|
||||||
|
# "options": {"data": {...GET/PUT options 응답...}, "written_at": ISO},
|
||||||
|
# "variants": {"data": {variant_code: {...}}, "written_at": ISO}}
|
||||||
|
# 섹션별로 따로 갱신한다(가격만 바꿨는데 옵션 스냅샷이 사라지면 안 된다).
|
||||||
|
def save_write_snapshot(self, product_no: int, section: str, data: Any) -> None:
|
||||||
|
from psycopg.types.json import Jsonb # noqa: WPS433
|
||||||
|
|
||||||
|
no = int(product_no)
|
||||||
|
if no <= 0 or section not in ("product", "options", "variants"):
|
||||||
|
return
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
with conn.transaction():
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT last_write_snapshot FROM cafe24_products WHERE product_no = %s FOR UPDATE",
|
||||||
|
(no,),
|
||||||
|
).fetchone()
|
||||||
|
current = dict(row["last_write_snapshot"]) if row and row.get("last_write_snapshot") else {}
|
||||||
|
if section == "variants" and isinstance(data, dict):
|
||||||
|
# 품목은 코드별로 누적 병합 — 일부만 바꿔도 이전에 바꾼 것을 잃지 않게.
|
||||||
|
merged = dict((current.get("variants") or {}).get("data") or {})
|
||||||
|
merged.update(data)
|
||||||
|
data = merged
|
||||||
|
current[section] = {
|
||||||
|
"data": data,
|
||||||
|
"written_at": datetime.now(KST).isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
product = data if section == "product" and isinstance(data, dict) else {}
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cafe24_products
|
||||||
|
(product_no, product_code, product_name, display, selling,
|
||||||
|
last_synced_at, last_write_snapshot, last_written_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, now(), %s, now())
|
||||||
|
ON CONFLICT (product_no) DO UPDATE SET
|
||||||
|
product_code = COALESCE(NULLIF(EXCLUDED.product_code, ''), cafe24_products.product_code),
|
||||||
|
product_name = COALESCE(NULLIF(EXCLUDED.product_name, ''), cafe24_products.product_name),
|
||||||
|
display = CASE WHEN %s THEN EXCLUDED.display ELSE cafe24_products.display END,
|
||||||
|
selling = CASE WHEN %s THEN EXCLUDED.selling ELSE cafe24_products.selling END,
|
||||||
|
last_synced_at = now(),
|
||||||
|
last_write_snapshot = EXCLUDED.last_write_snapshot,
|
||||||
|
last_written_at = now()
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
no,
|
||||||
|
str(product.get("product_code") or ""),
|
||||||
|
str(product.get("product_name") or ""),
|
||||||
|
_flag_bool(product.get("display"), True),
|
||||||
|
_flag_bool(product.get("selling"), True),
|
||||||
|
Jsonb(current),
|
||||||
|
bool(product),
|
||||||
|
bool(product),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_write_snapshot(self, product_no: int) -> dict[str, Any]:
|
||||||
|
"""섹션별 스냅샷 dict. 없으면 {}."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT last_write_snapshot FROM cafe24_products WHERE product_no = %s",
|
||||||
|
(int(product_no),),
|
||||||
|
).fetchone()
|
||||||
|
if not row or not row.get("last_write_snapshot"):
|
||||||
|
return {}
|
||||||
|
return dict(row["last_write_snapshot"])
|
||||||
|
|
||||||
|
# ── 읽기 지연 판정용 revision 조회 ──
|
||||||
|
def latest_write_revision(self, product_no: int, *, since: datetime) -> dict[str, Any] | None:
|
||||||
|
"""유예시간 안의 가장 최근 '쓰기' revision(MANUAL/SCHEDULED/ROLLBACK) — HTML 포함."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, product_no, revision_type, html_content, memo, created_by, created_at
|
||||||
|
FROM cafe24_product_revisions
|
||||||
|
WHERE product_no = %s
|
||||||
|
AND revision_type = ANY(%s)
|
||||||
|
AND created_at >= %s
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(int(product_no), list(store.WRITE_REVISION_TYPES), since),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
out = dict(row)
|
||||||
|
at = out.get("created_at")
|
||||||
|
if isinstance(at, datetime) and at.tzinfo is None:
|
||||||
|
out["created_at"] = at.replace(tzinfo=KST)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def revision_digests(self, product_no: int, *, since: datetime) -> set[str]:
|
||||||
|
"""유예시간 안의 revision 들의 내용 해시(store.content_digest 와 같은 md5 hex).
|
||||||
|
|
||||||
|
내용을 통째로 옮기지 않고 DB 에서 해시만 계산한다(상세페이지는 수 MB 일 수 있다).
|
||||||
|
md5 를 쓰는 이유: 모든 PostgreSQL 버전에 있고, 여기서는 충돌 저항이 아니라
|
||||||
|
"같은 내용인가"만 필요하다.
|
||||||
|
"""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT md5(html_content) AS digest
|
||||||
|
FROM cafe24_product_revisions
|
||||||
|
WHERE product_no = %s AND created_at >= %s
|
||||||
|
""",
|
||||||
|
(int(product_no), since),
|
||||||
|
).fetchall()
|
||||||
|
return {str(r["digest"]) for r in rows if r.get("digest")}
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
# 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다)
|
# 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다)
|
||||||
# 쓰기 직전 BACKUP 을 남기는 것이 유일한 복구 수단이다.
|
# 쓰기 직전 BACKUP 을 남기는 것이 유일한 복구 수단이다.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에
|
라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에
|
||||||
확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다.
|
확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다.
|
||||||
routes_products 상품 목록/검색 · 상세설명 조회·편집·적용
|
routes_products 상품 목록/검색 · 상세설명 조회·편집·적용
|
||||||
|
routes_product_info 오른쪽 정보 패널 JSON API — 상품명/가격 · 대표이미지 ·
|
||||||
|
옵션(생성/수정/삭제) · 품목(자체코드/추가금액/진열/판매)
|
||||||
routes_schedules 예약 등록·목록·취소 (실행은 worker.py)
|
routes_schedules 예약 등록·목록·취소 (실행은 worker.py)
|
||||||
routes_system 연결(OAuth)·상태·API 로그·작업 로그
|
routes_system 연결(OAuth)·상태·API 로그·작업 로그
|
||||||
routes_design_files 모바일 스와이프·PC/모바일 상품상세 템플릿 편집 —
|
routes_design_files 모바일 스와이프·PC/모바일 상품상세 템플릿 편집 —
|
||||||
@@ -24,6 +26,7 @@ import logging
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from .routes_design_files import design_files_router
|
from .routes_design_files import design_files_router
|
||||||
|
from .routes_product_info import product_info_router
|
||||||
from .routes_products import products_router
|
from .routes_products import products_router
|
||||||
from .routes_schedules import schedules_router
|
from .routes_schedules import schedules_router
|
||||||
from .routes_system import system_router
|
from .routes_system import system_router
|
||||||
@@ -33,6 +36,7 @@ logger = logging.getLogger("cafe24.router")
|
|||||||
router = APIRouter(prefix="/cafe24", tags=["cafe24"])
|
router = APIRouter(prefix="/cafe24", tags=["cafe24"])
|
||||||
|
|
||||||
router.include_router(products_router)
|
router.include_router(products_router)
|
||||||
|
router.include_router(product_info_router)
|
||||||
router.include_router(schedules_router)
|
router.include_router(schedules_router)
|
||||||
router.include_router(design_files_router)
|
router.include_router(design_files_router)
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
|
|||||||
@@ -0,0 +1,498 @@
|
|||||||
|
"""카페24 상품 정보 패널 — 오른쪽 칸 JSON API.
|
||||||
|
|
||||||
|
화면(products.html 의 `_side.html` 조각)이 fetch 로 부른다. 전부 `def`(동기) 핸들러.
|
||||||
|
|
||||||
|
POST /products/{no}/basic 상품명 · 판매가 · 공급가 · 소비자가
|
||||||
|
POST /products/{no}/image 대표 이미지 교체 (multipart 파일)
|
||||||
|
GET /products/{no}/options 옵션 + 품목 조회 (읽기 지연 보정 포함)
|
||||||
|
POST /products/{no}/options 옵션 생성 (옵션 없는 상품 — 품목 자동 생성)
|
||||||
|
PUT /products/{no}/options 옵션명 · 옵션값 이름/이미지/표시방식 수정
|
||||||
|
DELETE /products/{no}/options 옵션 삭제 (품목도 함께 삭제 — 확인 후)
|
||||||
|
POST /products/{no}/options/image 옵션값 썸네일 업로드만 (경로 반환)
|
||||||
|
PUT /products/{no}/variants 품목 자체코드 · 추가금액 · 진열 · 판매
|
||||||
|
|
||||||
|
공통 규칙
|
||||||
|
- 카페24 호출은 `app.integrations.cafe24` 만 통한다(httpx 직접 호출 금지).
|
||||||
|
- 쓰기 전에 현재값을 읽되, **읽기 지연을 보정한 유효 현재값**과 비교해 바뀐
|
||||||
|
것만 보낸다(routes_products.load_product). 이걸 안 하면 "A→B 로 바꾼 직후
|
||||||
|
다시 B→A" 가 카페24의 예전 값(A)과 같다고 판단돼 무시된다.
|
||||||
|
- PUT 응답이 곧 현재값이다. 응답을 스냅샷(`save_write_snapshot`)에 남기고 화면도
|
||||||
|
응답으로 그린다 — 다시 GET 하지 않는다(GET 은 한동안 예전 값을 돌려준다).
|
||||||
|
- 모든 쓰기는 감사로그(`cafe24_audit_logs`)에 남긴다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, File, HTTPException, Request, UploadFile
|
||||||
|
|
||||||
|
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
|
||||||
|
|
||||||
|
from . import store
|
||||||
|
from .common import read_lag_grace_minutes, require_store
|
||||||
|
from .routes_products import load_product, remember_write
|
||||||
|
|
||||||
|
logger = logging.getLogger("cafe24.product_info")
|
||||||
|
|
||||||
|
product_info_router = APIRouter()
|
||||||
|
|
||||||
|
# 업로드 허용 이미지 형식 (카페24 상품 이미지 규격)
|
||||||
|
_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||||
|
|
||||||
|
|
||||||
|
def _http_from_cafe24(exc: Cafe24Error) -> HTTPException:
|
||||||
|
return HTTPException(status_code=502, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_image(upload: UploadFile) -> bytes:
|
||||||
|
"""업로드 파일 검증 + 바이트. 형식/크기 오류는 400."""
|
||||||
|
content_type = (upload.content_type or "").lower()
|
||||||
|
if content_type not in _IMAGE_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail="JPG·PNG·GIF·WEBP 이미지만 올릴 수 있습니다.")
|
||||||
|
data = upload.file.read(products.IMAGE_MAX_BYTES + 1)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status_code=400, detail="빈 파일입니다.")
|
||||||
|
if len(data) > products.IMAGE_MAX_BYTES:
|
||||||
|
raise HTTPException(status_code=400, detail="이미지는 10MB 를 넘을 수 없습니다.")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _scalar_view(product: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""화면이 바로 쓰는 스칼라 값들(PUT 응답 또는 보정된 GET 에서)."""
|
||||||
|
snap = store.product_snapshot(product)
|
||||||
|
return {
|
||||||
|
"product_name": snap.get("product_name", ""),
|
||||||
|
"price": snap.get("price", ""),
|
||||||
|
"supply_price": snap.get("supply_price", ""),
|
||||||
|
"retail_price": snap.get("retail_price", ""),
|
||||||
|
"display": products._flag(snap.get("display")), # noqa: SLF001 — 같은 규칙 재사용
|
||||||
|
"selling": products._flag(snap.get("selling")), # noqa: SLF001
|
||||||
|
"detail_image": snap.get("detail_image", ""),
|
||||||
|
"list_image": snap.get("list_image", ""),
|
||||||
|
"tiny_image": snap.get("tiny_image", ""),
|
||||||
|
"small_image": snap.get("small_image", ""),
|
||||||
|
"updated_date": snap.get("updated_date", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 기본 정보 — 상품명 · 가격
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
@product_info_router.post("/products/{product_no}/basic")
|
||||||
|
def product_basic(
|
||||||
|
request: Request,
|
||||||
|
product_no: int,
|
||||||
|
payload: dict[str, Any] = Body(default_factory=dict),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
st, user = require_store(request)
|
||||||
|
actor = str(user.get("email") or "")
|
||||||
|
|
||||||
|
want: dict[str, str] = {}
|
||||||
|
if "product_name" in payload:
|
||||||
|
name = str(payload.get("product_name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="상품명을 입력하세요.")
|
||||||
|
if len(name) > store.NAME_MAX:
|
||||||
|
raise HTTPException(status_code=400, detail=f"상품명은 {store.NAME_MAX}자를 넘을 수 없습니다.")
|
||||||
|
want["product_name"] = name
|
||||||
|
try:
|
||||||
|
for key, label in (("price", "판매가"), ("supply_price", "공급가"), ("retail_price", "소비자가")):
|
||||||
|
if key in payload:
|
||||||
|
parsed = store.parse_price(payload.get(key), field=label)
|
||||||
|
if parsed is not None:
|
||||||
|
want[key] = parsed
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
if not want:
|
||||||
|
raise HTTPException(status_code=400, detail="바꿀 항목이 없습니다.")
|
||||||
|
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
try:
|
||||||
|
current, _ = load_product(st, api, product_no)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
st.log_audit(actor=actor, action="update_basic", product_no=product_no,
|
||||||
|
result="FAIL", detail=f"현재값 조회 실패: {exc}")
|
||||||
|
raise _http_from_cafe24(exc) from exc
|
||||||
|
if not current:
|
||||||
|
raise HTTPException(status_code=404, detail="카페24에서 상품을 찾지 못했습니다.")
|
||||||
|
|
||||||
|
changes: dict[str, str] = {}
|
||||||
|
before: dict[str, str] = {}
|
||||||
|
for key, value in want.items():
|
||||||
|
old = str(current.get(key) or "")
|
||||||
|
same = old == value if key == "product_name" else store.price_equal(old, value)
|
||||||
|
if not same:
|
||||||
|
changes[key] = value
|
||||||
|
before[key] = old
|
||||||
|
if not changes:
|
||||||
|
return {"ok": True, "changed": [], "product": _scalar_view(current)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated = products.update_product(api.client, product_no, **changes)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
st.log_audit(actor=actor, action="update_basic", product_no=product_no,
|
||||||
|
result="FAIL", detail=f"{changes} 실패: {exc}")
|
||||||
|
logger.warning("카페24 상품 %s 기본정보 변경 실패: %s", product_no, exc)
|
||||||
|
raise _http_from_cafe24(exc) from exc
|
||||||
|
|
||||||
|
remember_write(st, product_no, updated if isinstance(updated, dict) else {})
|
||||||
|
merged = {**current, **(updated if isinstance(updated, dict) else {}), **changes}
|
||||||
|
detail = ", ".join(f"{k}: '{before[k]}' → '{v}'" for k, v in changes.items())
|
||||||
|
st.log_audit(actor=actor, action="update_basic", product_no=product_no,
|
||||||
|
result="SUCCESS", detail=detail)
|
||||||
|
logger.info("카페24 상품 %s 기본정보 변경 (%s): %s", product_no, actor, detail)
|
||||||
|
return {"ok": True, "changed": list(changes.keys()), "product": _scalar_view(merged)}
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 대표 이미지
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
@product_info_router.post("/products/{product_no}/image")
|
||||||
|
def product_image(
|
||||||
|
request: Request,
|
||||||
|
product_no: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""대표 이미지 교체. 업로드(/products/images) → PUT detail_image(A 타입).
|
||||||
|
|
||||||
|
A(대표이미지등록) 타입이면 목록/작은목록/축소 이미지는 카페24가 리사이징한다.
|
||||||
|
이전 이미지 경로는 감사로그에 남긴다(되돌리려면 그 경로를 다시 넣는다).
|
||||||
|
"""
|
||||||
|
st, user = require_store(request)
|
||||||
|
actor = str(user.get("email") or "")
|
||||||
|
data = _read_image(file)
|
||||||
|
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
try:
|
||||||
|
current, _ = load_product(st, api, product_no)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
raise _http_from_cafe24(exc) from exc
|
||||||
|
before = str(current.get("detail_image") or "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
path = products.upload_image_bytes(api.client, data)
|
||||||
|
if not path:
|
||||||
|
raise Cafe24Error("카페24가 업로드 경로를 돌려주지 않았습니다.")
|
||||||
|
updated = products.set_main_image(api.client, product_no, path)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
st.log_audit(actor=actor, action="set_main_image", product_no=product_no,
|
||||||
|
result="FAIL", detail=f"업로드/적용 실패: {exc}")
|
||||||
|
logger.warning("카페24 상품 %s 대표이미지 변경 실패: %s", product_no, exc)
|
||||||
|
raise _http_from_cafe24(exc) from exc
|
||||||
|
|
||||||
|
remember_write(st, product_no, updated if isinstance(updated, dict) else {})
|
||||||
|
merged = {**current, **(updated if isinstance(updated, dict) else {})}
|
||||||
|
if not merged.get("detail_image"):
|
||||||
|
merged["detail_image"] = path
|
||||||
|
st.log_audit(actor=actor, action="set_main_image", product_no=product_no,
|
||||||
|
result="SUCCESS", detail=f"'{before}' → '{merged.get('detail_image')}' (업로드 {path})")
|
||||||
|
logger.info("카페24 상품 %s 대표이미지 변경 (%s)", product_no, actor)
|
||||||
|
return {"ok": True, "product": _scalar_view(merged)}
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 옵션 · 품목
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def _variant_view(v: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
opts = v.get("options") if isinstance(v.get("options"), list) else []
|
||||||
|
return {
|
||||||
|
"variant_code": str(v.get("variant_code") or ""),
|
||||||
|
"options": [
|
||||||
|
{"name": str(o.get("name") or ""), "value": str(o.get("value") or "")}
|
||||||
|
for o in opts if isinstance(o, dict)
|
||||||
|
],
|
||||||
|
"custom_variant_code": str(v.get("custom_variant_code") or ""),
|
||||||
|
"additional_amount": str(v.get("additional_amount") or "0.00"),
|
||||||
|
"display": products._flag(v.get("display")), # noqa: SLF001
|
||||||
|
"selling": products._flag(v.get("selling")), # noqa: SLF001
|
||||||
|
"quantity": v.get("quantity"),
|
||||||
|
"use_inventory": products._flag(v.get("use_inventory"), default=False), # noqa: SLF001
|
||||||
|
"image": str(v.get("image") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _options_view(option: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
raw_options = option.get("options") if isinstance(option.get("options"), list) else []
|
||||||
|
out_options = []
|
||||||
|
for o in raw_options:
|
||||||
|
if not isinstance(o, dict):
|
||||||
|
continue
|
||||||
|
values = o.get("option_value") if isinstance(o.get("option_value"), list) else []
|
||||||
|
out_options.append(
|
||||||
|
{
|
||||||
|
"option_code": str(o.get("option_code") or ""),
|
||||||
|
"option_name": str(o.get("option_name") or ""),
|
||||||
|
"option_display_type": str(o.get("option_display_type") or "S"),
|
||||||
|
"required_option": str(o.get("required_option") or "T"),
|
||||||
|
"option_value": [
|
||||||
|
{
|
||||||
|
"option_text": str(v.get("option_text") or ""),
|
||||||
|
"option_image_file": str(v.get("option_image_file") or ""),
|
||||||
|
"option_link_image": str(v.get("option_link_image") or ""),
|
||||||
|
"option_color": str(v.get("option_color") or ""),
|
||||||
|
"value_no": v.get("value_no"),
|
||||||
|
}
|
||||||
|
for v in values if isinstance(v, dict)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"has_option": products._flag(option.get("has_option"), default=False), # noqa: SLF001
|
||||||
|
"option_type": str(option.get("option_type") or ""),
|
||||||
|
"option_list_type": str(option.get("option_list_type") or ""),
|
||||||
|
"option_preset_code": str(option.get("option_preset_code") or ""),
|
||||||
|
"options": out_options,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_variant_snapshot(st: Any, product_no: int, variants: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""GET 품목 목록에 유예시간 안의 우리 쓰기(스냅샷)를 덮어씌운다."""
|
||||||
|
section = (st.get_write_snapshot(product_no) or {}).get("variants") or {}
|
||||||
|
data = section.get("data") or {}
|
||||||
|
if not data or not store.within_grace(section.get("written_at"), grace_minutes=read_lag_grace_minutes()):
|
||||||
|
return variants
|
||||||
|
out = []
|
||||||
|
for v in variants:
|
||||||
|
code = str(v.get("variant_code") or "")
|
||||||
|
patch = data.get(code)
|
||||||
|
out.append({**v, **patch} if isinstance(patch, dict) else v)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_options_snapshot(st: Any, product_no: int, option: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""GET 옵션이 우리 마지막 쓰기와 다르고 유예시간 안이면 스냅샷(우리 쓰기)을 쓴다."""
|
||||||
|
section = (st.get_write_snapshot(product_no) or {}).get("options") or {}
|
||||||
|
data = section.get("data")
|
||||||
|
if not isinstance(data, dict) or not store.within_grace(
|
||||||
|
section.get("written_at"), grace_minutes=read_lag_grace_minutes()
|
||||||
|
):
|
||||||
|
return option
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _load_options_and_variants(st: Any, api: Any, product_no: int) -> dict[str, Any]:
|
||||||
|
option = _apply_options_snapshot(st, product_no, products.get_options(api.client, product_no))
|
||||||
|
view = _options_view(option)
|
||||||
|
variants: list[dict[str, Any]] = []
|
||||||
|
if view["has_option"]:
|
||||||
|
variants = _apply_variant_snapshot(st, product_no, products.list_variants(api.client, product_no))
|
||||||
|
return {"option": view, "variants": [_variant_view(v) for v in variants], "raw_options": option}
|
||||||
|
|
||||||
|
|
||||||
|
@product_info_router.get("/products/{product_no}/options")
|
||||||
|
def options_get(request: Request, product_no: int) -> dict[str, Any]:
|
||||||
|
st, _user = require_store(request)
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
try:
|
||||||
|
loaded = _load_options_and_variants(st, api, product_no)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
raise _http_from_cafe24(exc) from exc
|
||||||
|
return {"ok": True, "option": loaded["option"], "variants": loaded["variants"],
|
||||||
|
"display_types": store.OPTION_DISPLAY_LABELS}
|
||||||
|
|
||||||
|
|
||||||
|
@product_info_router.post("/products/{product_no}/options")
|
||||||
|
def options_create(
|
||||||
|
request: Request,
|
||||||
|
product_no: int,
|
||||||
|
payload: dict[str, Any] = Body(default_factory=dict),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""옵션 없는 상품에 조합형 옵션 1개(옵션명 + 옵션값들)를 만든다. 품목은 자동 생성."""
|
||||||
|
st, user = require_store(request)
|
||||||
|
actor = str(user.get("email") or "")
|
||||||
|
try:
|
||||||
|
body = store.build_create_options_request(
|
||||||
|
str(payload.get("option_name") or ""),
|
||||||
|
store.parse_option_values(payload.get("values")),
|
||||||
|
display_type=str(payload.get("display_type") or "S"),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
try:
|
||||||
|
existing = products.get_options(api.client, product_no)
|
||||||
|
if products._flag(existing.get("has_option"), default=False): # noqa: SLF001
|
||||||
|
raise HTTPException(status_code=409, detail="이미 옵션이 있는 상품입니다. 수정하거나 삭제한 뒤 다시 만드세요.")
|
||||||
|
created = products.create_options(api.client, product_no, body)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
st.log_audit(actor=actor, action="create_options", product_no=product_no,
|
||||||
|
result="FAIL", detail=f"{body['options'][0]['option_name']} 실패: {exc}")
|
||||||
|
raise _http_from_cafe24(exc) from exc
|
||||||
|
|
||||||
|
st.save_write_snapshot(product_no, "options", created)
|
||||||
|
values = [v["option_text"] for v in body["options"][0]["option_value"]]
|
||||||
|
st.log_audit(actor=actor, action="create_options", product_no=product_no, result="SUCCESS",
|
||||||
|
detail=f"옵션 '{body['options'][0]['option_name']}' 생성: {', '.join(values)}")
|
||||||
|
logger.info("카페24 상품 %s 옵션 생성 (%s)", product_no, actor)
|
||||||
|
try:
|
||||||
|
variants = products.list_variants(api.client, product_no)
|
||||||
|
except Cafe24Error as exc: # 옵션은 만들어졌다 — 품목 목록만 비워서 돌려준다.
|
||||||
|
logger.warning("카페24 상품 %s 옵션 생성 후 품목 조회 실패: %s", product_no, exc)
|
||||||
|
variants = []
|
||||||
|
return {"ok": True, "option": _options_view(created), "variants": [_variant_view(v) for v in variants]}
|
||||||
|
|
||||||
|
|
||||||
|
@product_info_router.put("/products/{product_no}/options")
|
||||||
|
def options_update(
|
||||||
|
request: Request,
|
||||||
|
product_no: int,
|
||||||
|
payload: dict[str, Any] = Body(default_factory=dict),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""옵션명 · 옵션값 이름/썸네일/연결이미지/색상 · 표시방식 수정.
|
||||||
|
|
||||||
|
카페24 PUT 은 `original_options`(수정 전)와 `options`(수정 후)를 짝지어 받는다.
|
||||||
|
수정 전 값은 화면이 들고 있던 것이 아니라 **지금 카페24에서 다시 읽은 값**(읽기
|
||||||
|
지연 보정 후)을 쓴다 — 그래야 다른 곳에서 바뀐 이름과 어긋나지 않는다.
|
||||||
|
"""
|
||||||
|
st, user = require_store(request)
|
||||||
|
actor = str(user.get("email") or "")
|
||||||
|
edited = payload.get("options")
|
||||||
|
if not isinstance(edited, list) or not edited:
|
||||||
|
raise HTTPException(status_code=400, detail="옵션 목록이 비어 있습니다.")
|
||||||
|
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
try:
|
||||||
|
loaded = _load_options_and_variants(st, api, product_no)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
raise _http_from_cafe24(exc) from exc
|
||||||
|
if not loaded["option"]["has_option"]:
|
||||||
|
raise HTTPException(status_code=409, detail="옵션이 없는 상품입니다. 먼저 옵션을 만드세요.")
|
||||||
|
original = loaded["option"]["options"]
|
||||||
|
try:
|
||||||
|
body = store.build_update_options_request(
|
||||||
|
original, edited, option_list_type=str(payload.get("option_list_type") or "")
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
if loaded["option"]["option_preset_code"]:
|
||||||
|
body["option_preset_code"] = loaded["option"]["option_preset_code"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated = products.update_options(api.client, product_no, body)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
st.log_audit(actor=actor, action="update_options", product_no=product_no,
|
||||||
|
result="FAIL", detail=str(exc))
|
||||||
|
logger.warning("카페24 상품 %s 옵션 수정 실패: %s", product_no, exc)
|
||||||
|
raise _http_from_cafe24(exc) from exc
|
||||||
|
|
||||||
|
st.save_write_snapshot(product_no, "options", updated)
|
||||||
|
summary = "; ".join(
|
||||||
|
f"{o['option_name']}: " + ", ".join(v["option_text"] for v in o["option_value"])
|
||||||
|
for o in body["options"]
|
||||||
|
)
|
||||||
|
st.log_audit(actor=actor, action="update_options", product_no=product_no,
|
||||||
|
result="SUCCESS", detail=summary[:900])
|
||||||
|
logger.info("카페24 상품 %s 옵션 수정 (%s)", product_no, actor)
|
||||||
|
# 품목의 옵션값 표기는 이름을 바꾼 만큼 달라진다 — 응답값으로 다시 맞춘다.
|
||||||
|
variants = loaded["variants"]
|
||||||
|
rename: dict[tuple[str, str], tuple[str, str]] = {}
|
||||||
|
for o, n in zip(original, body["options"]):
|
||||||
|
for ov, nv in zip(o["option_value"], n["option_value"]):
|
||||||
|
rename[(o["option_name"], ov["option_text"])] = (n["option_name"], nv["option_text"])
|
||||||
|
for v in variants:
|
||||||
|
v["options"] = [
|
||||||
|
dict(zip(("name", "value"), rename.get((opt["name"], opt["value"]), (opt["name"], opt["value"]))))
|
||||||
|
for opt in v["options"]
|
||||||
|
]
|
||||||
|
return {"ok": True, "option": _options_view(updated), "variants": variants}
|
||||||
|
|
||||||
|
|
||||||
|
@product_info_router.delete("/products/{product_no}/options")
|
||||||
|
def options_delete(request: Request, product_no: int) -> dict[str, Any]:
|
||||||
|
"""옵션 삭제 — 카페24가 품목도 함께 지운다. 화면에서 확인창을 거친다."""
|
||||||
|
st, user = require_store(request)
|
||||||
|
actor = str(user.get("email") or "")
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
try:
|
||||||
|
before = products.get_options(api.client, product_no)
|
||||||
|
products.delete_options(api.client, product_no)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
st.log_audit(actor=actor, action="delete_options", product_no=product_no,
|
||||||
|
result="FAIL", detail=str(exc))
|
||||||
|
raise _http_from_cafe24(exc) from exc
|
||||||
|
names = ", ".join(
|
||||||
|
str(o.get("option_name") or "") for o in (before.get("options") or []) if isinstance(o, dict)
|
||||||
|
)
|
||||||
|
st.save_write_snapshot(product_no, "options", {"has_option": "F", "options": []})
|
||||||
|
st.save_write_snapshot(product_no, "variants", {})
|
||||||
|
st.log_audit(actor=actor, action="delete_options", product_no=product_no,
|
||||||
|
result="SUCCESS", detail=f"삭제된 옵션: {names or '(없음)'}")
|
||||||
|
logger.info("카페24 상품 %s 옵션 삭제 (%s)", product_no, actor)
|
||||||
|
return {"ok": True, "option": _options_view({"has_option": "F"}), "variants": []}
|
||||||
|
|
||||||
|
|
||||||
|
@product_info_router.post("/products/{product_no}/options/image")
|
||||||
|
def option_image_upload(
|
||||||
|
request: Request,
|
||||||
|
product_no: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""옵션값 썸네일 업로드만 한다. 경로를 돌려주면 화면이 옵션 저장(PUT)에 담는다."""
|
||||||
|
st, user = require_store(request)
|
||||||
|
actor = str(user.get("email") or "")
|
||||||
|
data = _read_image(file)
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
try:
|
||||||
|
path = products.upload_image_bytes(api.client, data)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
st.log_audit(actor=actor, action="upload_option_image", product_no=product_no,
|
||||||
|
result="FAIL", detail=str(exc))
|
||||||
|
raise _http_from_cafe24(exc) from exc
|
||||||
|
if not path:
|
||||||
|
raise HTTPException(status_code=502, detail="카페24가 업로드 경로를 돌려주지 않았습니다.")
|
||||||
|
st.log_audit(actor=actor, action="upload_option_image", product_no=product_no,
|
||||||
|
result="SUCCESS", detail=path)
|
||||||
|
return {"ok": True, "path": path}
|
||||||
|
|
||||||
|
|
||||||
|
@product_info_router.put("/products/{product_no}/variants")
|
||||||
|
def variants_update(
|
||||||
|
request: Request,
|
||||||
|
product_no: int,
|
||||||
|
payload: dict[str, Any] = Body(default_factory=dict),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""품목 여러 건의 자체코드 · 추가금액 · 진열 · 판매를 한 번에 바꾼다."""
|
||||||
|
st, user = require_store(request)
|
||||||
|
actor = str(user.get("email") or "")
|
||||||
|
rows = payload.get("rows")
|
||||||
|
if not isinstance(rows, list) or not rows:
|
||||||
|
raise HTTPException(status_code=400, detail="바꿀 품목이 없습니다.")
|
||||||
|
try:
|
||||||
|
requests = store.build_variant_updates(rows)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
if not requests:
|
||||||
|
raise HTTPException(status_code=400, detail="바뀐 값이 없습니다.")
|
||||||
|
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
try:
|
||||||
|
results = products.update_variants(api.client, product_no, requests)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
st.log_audit(actor=actor, action="update_variants", product_no=product_no,
|
||||||
|
result="FAIL", detail=f"{len(requests)}건 실패: {exc}")
|
||||||
|
logger.warning("카페24 상품 %s 품목 수정 실패: %s", product_no, exc)
|
||||||
|
raise _http_from_cafe24(exc) from exc
|
||||||
|
|
||||||
|
# 우리가 보낸 값이 곧 현재값이다(카페24가 받아들였으므로). 응답에 담긴 값이 있으면
|
||||||
|
# 그것을 우선한다.
|
||||||
|
by_code: dict[str, dict[str, Any]] = {}
|
||||||
|
for r in requests:
|
||||||
|
by_code[r["variant_code"]] = {k: v for k, v in r.items() if k != "variant_code"}
|
||||||
|
for r in results:
|
||||||
|
code = str(r.get("variant_code") or "")
|
||||||
|
if code in by_code:
|
||||||
|
for key in ("custom_variant_code", "additional_amount", "display", "selling"):
|
||||||
|
if key in r and r[key] is not None:
|
||||||
|
by_code[code][key] = r[key]
|
||||||
|
st.save_write_snapshot(product_no, "variants", by_code)
|
||||||
|
detail = "; ".join(
|
||||||
|
f"{code}: " + ", ".join(f"{k}={v}" for k, v in patch.items()) for code, patch in by_code.items()
|
||||||
|
)
|
||||||
|
st.log_audit(actor=actor, action="update_variants", product_no=product_no,
|
||||||
|
result="SUCCESS", detail=detail[:900])
|
||||||
|
logger.info("카페24 상품 %s 품목 %s건 수정 (%s)", product_no, len(by_code), actor)
|
||||||
|
return {"ok": True, "updated": {code: _variant_view({"variant_code": code, **patch})
|
||||||
|
for code, patch in by_code.items()}}
|
||||||
@@ -1,22 +1,38 @@
|
|||||||
"""카페24 상품 화면 — 좌우 2분할(목록 | 상세페이지 편집).
|
"""카페24 상품 화면 — 좌우 3분할(목록 | 상세페이지 편집 | 상품 정보 패널).
|
||||||
|
|
||||||
화면 구성
|
화면 구성
|
||||||
왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬.
|
왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬.
|
||||||
오른쪽 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게.
|
가운데 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게.
|
||||||
|
오른쪽 상품 정보 패널 — 상품명/판매가/공급가 · 대표 이미지 · 옵션/품목.
|
||||||
|
(JSON API 는 routes_product_info.py)
|
||||||
|
|
||||||
목록은 페이지를 넘겨가며 **전체**를 한 번에 받는다(`list_all_products`). 필터·정렬을
|
목록은 페이지를 넘겨가며 **전체**를 한 번에 받는다(`list_all_products`). 필터·정렬을
|
||||||
브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음
|
브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음
|
||||||
페이지에 있는 해당 상품이 빠진다.
|
페이지에 있는 해당 상품이 빠진다.
|
||||||
|
|
||||||
상품을 클릭하면 오른쪽만 교체한다(`GET /products/{no}/pane` 이 편집기 조각을
|
상품을 클릭하면 가운데·오른쪽만 교체한다(`GET /products/{no}/pane` 이 두 조각을
|
||||||
돌려주고 JS 가 끼워 넣는다). 목록을 다시 불러오지 않으므로 카페24 호출이 1회로
|
한 응답으로 돌려주고 JS 가 각각 끼워 넣는다). 목록을 다시 불러오지 않으므로
|
||||||
끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크(`/cafe24/?selected=`)로 동작한다.
|
카페24 호출이 1회로 끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크
|
||||||
|
(`/cafe24/?selected=`)로 동작한다.
|
||||||
|
|
||||||
쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다.
|
쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다.
|
||||||
카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT →
|
카페24 현재값 재조회 → 읽기 지연 판정(유효 현재값) → BACKUP 버전 저장 →
|
||||||
MANUAL 버전 + 감사로그
|
지문 대조(충돌 거부) → PUT → 스냅샷 + MANUAL 버전 + 감사로그
|
||||||
로컬 DB 의 마지막 버전을 "지금 카페24에 올라간 값"으로 가정하지 않는다.
|
로컬 DB 의 마지막 버전을 "지금 카페24에 올라간 값"으로 가정하지 않는다.
|
||||||
|
|
||||||
|
── 카페24 읽기 지연(read-after-write lag) ──
|
||||||
|
카페24 관리자 API 는 PUT 직후 한동안 GET 에서 **예전 값**을 돌려준다(실물 관찰,
|
||||||
|
몇 초에서 훨씬 길게). 우리 쪽 캐시 문제가 아니다(모든 응답 no-store). 그 값을 그대로
|
||||||
|
믿으면 "적용했는데 예전 소스가 보이고" 그 예전 값으로 지문을 만들어 다음 적용 때
|
||||||
|
충돌로 오판한다. 그래서 **마지막 쓰기가 권위**다:
|
||||||
|
- 상세설명: 카페24 값이 우리가 최근(유예시간 안)에 남긴 revision 중 하나와 같으면
|
||||||
|
"아직 예전 값" → 마지막 쓰기(MANUAL/SCHEDULED)를 보여주고 그 지문을 쓴다.
|
||||||
|
우리가 모르는 값이면 관리자에서 직접 고친 것 → 카페24 값을 믿는다.
|
||||||
|
(`store.resolve_description`, `_resolve_description`)
|
||||||
|
- 상품명/가격/이미지/진열/판매: PUT 응답을 스냅샷으로 남기고, GET 의 updated_date
|
||||||
|
가 스냅샷보다 이전이면 스냅샷으로 덮어씌운다(`store.overlay_recent_write`).
|
||||||
|
유예시간은 `CAFE24_READ_LAG_GRACE_MIN`(기본 360분). 지나면 무조건 카페24 값.
|
||||||
|
|
||||||
PC/모바일은 구분하지 않는다 — 적용 시 `description` 만 쓰고
|
PC/모바일은 구분하지 않는다 — 적용 시 `description` 만 쓰고
|
||||||
`separated_mobile_description="F"` 를 강제해 카페24가 모바일 값을 PC 와 자동으로
|
`separated_mobile_description="F"` 를 강제해 카페24가 모바일 값을 PC 와 자동으로
|
||||||
맞추게 한다(운영 방침). `mobile_description` 필드를 직접 보내면 카페24 관리자
|
맞추게 한다(운영 방침). `mobile_description` 필드를 직접 보내면 카페24 관리자
|
||||||
@@ -34,6 +50,7 @@ PC/모바일은 구분하지 않는다 — 적용 시 `description` 만 쓰고
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
@@ -42,9 +59,10 @@ from fastapi import APIRouter, Body, Form, HTTPException, Request
|
|||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
|
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
|
||||||
|
from app.timezone import now_kst
|
||||||
|
|
||||||
from . import store
|
from . import store
|
||||||
from .common import base_ctx, guard, require_store
|
from .common import base_ctx, guard, read_lag_grace_minutes, require_store
|
||||||
|
|
||||||
logger = logging.getLogger("cafe24.products")
|
logger = logging.getLogger("cafe24.products")
|
||||||
|
|
||||||
@@ -84,6 +102,17 @@ def _price(value: Any) -> str:
|
|||||||
return f"{won:,}원"
|
return f"{won:,}원"
|
||||||
|
|
||||||
|
|
||||||
|
def _price_plain(value: Any) -> str:
|
||||||
|
"""'6900.00' → '6900' (입력칸 초기값용)."""
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return str(int(Decimal(text)))
|
||||||
|
except (ArithmeticError, ValueError):
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _short_dt(value: Any) -> str:
|
def _short_dt(value: Any) -> str:
|
||||||
"""'2026-08-14T11:38:18+09:00' → '2026-08-14 11:38'."""
|
"""'2026-08-14T11:38:18+09:00' → '2026-08-14 11:38'."""
|
||||||
text = str(value or "").strip()
|
text = str(value or "").strip()
|
||||||
@@ -134,46 +163,122 @@ def _list_query(request: Request, *, selected: int | None = None) -> str:
|
|||||||
return urlencode(params)
|
return urlencode(params)
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 읽기 지연 보정 — 상품 dict 와 상세설명에 각각 적용한다.
|
||||||
|
# 다른 라우트(routes_product_info)도 같은 함수를 쓴다.
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def load_product(st: Any, api: Any, product_no: int) -> tuple[dict[str, Any], bool]:
|
||||||
|
"""카페24 GET + 최근 쓰기 스냅샷 덮어씌우기. (상품 dict, 스칼라 지연 여부).
|
||||||
|
|
||||||
|
지연 판정은 카페24의 updated_date 끼리 비교한다 — GET 이 PUT 응답보다 이전
|
||||||
|
레코드를 돌려주면 스냅샷 값(상품명·가격·이미지·진열/판매)을 쓴다.
|
||||||
|
"""
|
||||||
|
product = products.get_product(api.client, product_no)
|
||||||
|
if not product:
|
||||||
|
return product, False
|
||||||
|
section = (st.get_write_snapshot(product_no) or {}).get("product") or {}
|
||||||
|
merged, stale = store.overlay_recent_write(
|
||||||
|
product,
|
||||||
|
snapshot=section.get("data"),
|
||||||
|
written_at=section.get("written_at"),
|
||||||
|
grace_minutes=read_lag_grace_minutes(),
|
||||||
|
)
|
||||||
|
return merged, stale
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_description(
|
||||||
|
st: Any, product_no: int, cafe24_html: str
|
||||||
|
) -> tuple[str, str, dict[str, Any] | None]:
|
||||||
|
"""(유효 HTML, 상태, 마지막 쓰기 revision). 상태는 store.SYNC_*."""
|
||||||
|
grace = read_lag_grace_minutes()
|
||||||
|
since = now_kst() - timedelta(minutes=grace)
|
||||||
|
last_write = st.latest_write_revision(product_no, since=since)
|
||||||
|
if not last_write:
|
||||||
|
return cafe24_html, store.SYNC_NONE, None
|
||||||
|
digests = st.revision_digests(product_no, since=since)
|
||||||
|
effective, state = store.resolve_description(
|
||||||
|
cafe24_html, last_write=last_write, known_digests=digests, grace_minutes=grace
|
||||||
|
)
|
||||||
|
return effective, state, last_write
|
||||||
|
|
||||||
|
|
||||||
|
def remember_write(st: Any, product_no: int, updated: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""PUT 응답(상품 dict)을 캐시·스냅샷에 남긴다. 정규화된 캐시 행을 돌려준다."""
|
||||||
|
info = products.normalize_product(updated) if updated else {}
|
||||||
|
if info.get("product_no"):
|
||||||
|
st.upsert_products([info])
|
||||||
|
st.save_write_snapshot(product_no, "product", store.product_snapshot(updated))
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def _info(product: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""편집기·정보 패널 공용 상품 요약."""
|
||||||
|
info = products.normalize_product(product) if product else {}
|
||||||
|
return {
|
||||||
|
**info,
|
||||||
|
"price": _price(product.get("price")),
|
||||||
|
"price_plain": _price_plain(product.get("price")),
|
||||||
|
"supply_price": _price(product.get("supply_price")),
|
||||||
|
"supply_price_plain": _price_plain(product.get("supply_price")),
|
||||||
|
"retail_price": _price(product.get("retail_price")),
|
||||||
|
"retail_price_plain": _price_plain(product.get("retail_price")),
|
||||||
|
"updated_date": _short_dt(product.get("updated_date")),
|
||||||
|
"summary_description": product.get("summary_description") or "",
|
||||||
|
"detail_image": str(product.get("detail_image") or ""),
|
||||||
|
"list_image": str(product.get("list_image") or ""),
|
||||||
|
"tiny_image": str(product.get("tiny_image") or ""),
|
||||||
|
"small_image": str(product.get("small_image") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]:
|
def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]:
|
||||||
"""오른쪽 편집기 조각에 필요한 컨텍스트. 전체 페이지와 조각이 함께 쓴다."""
|
"""편집기(가운데)·정보 패널(오른쪽) 조각에 필요한 컨텍스트. 카페24 호출 1회."""
|
||||||
api = build_cafe24_api(st)
|
api = build_cafe24_api(st)
|
||||||
product: dict[str, Any] = {}
|
product: dict[str, Any] = {}
|
||||||
desc = None
|
desc = None
|
||||||
error = ""
|
error = ""
|
||||||
|
scalar_stale = False
|
||||||
|
html_effective = ""
|
||||||
|
sync_state = store.SYNC_NONE
|
||||||
|
last_write: dict[str, Any] | None = None
|
||||||
try:
|
try:
|
||||||
product = products.get_product(api.client, product_no)
|
product, scalar_stale = load_product(st, api, product_no)
|
||||||
desc = products.descriptions_from_product(product)
|
desc = products.descriptions_from_product(product)
|
||||||
st.upsert_products([products.normalize_product(product)])
|
st.upsert_products([products.normalize_product(product)])
|
||||||
|
html_effective, sync_state, last_write = resolve_description(st, product_no, desc.description)
|
||||||
except Cafe24Error as exc:
|
except Cafe24Error as exc:
|
||||||
error = str(exc)
|
error = str(exc)
|
||||||
logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc)
|
logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc)
|
||||||
|
|
||||||
info = products.normalize_product(product) if product else {}
|
|
||||||
return {
|
return {
|
||||||
"product_no": product_no,
|
"product_no": product_no,
|
||||||
# 고객이 보는 상세페이지 주소 (CAFE24_SHOP_URL, 없으면 카페24 기본 도메인)
|
# 고객이 보는 상세페이지 주소 (CAFE24_SHOP_URL, 없으면 카페24 기본 도메인)
|
||||||
"product_url": api.config.product_url(product_no),
|
"product_url": api.config.product_url(product_no),
|
||||||
"info": {
|
"info": _info(product),
|
||||||
**info,
|
|
||||||
"price": _price(product.get("price")),
|
|
||||||
"updated_date": _short_dt(product.get("updated_date")),
|
|
||||||
"summary_description": product.get("summary_description") or "",
|
|
||||||
},
|
|
||||||
"desc": desc,
|
"desc": desc,
|
||||||
# 편집기에는 (1) 이미지 경로의 %EC%9A%A9… 을 한글로 풀고
|
# 편집기에는 (1) 이미지 경로의 %EC%9A%A9… 을 한글로 풀고
|
||||||
# (2) 태그마다 줄을 나눠 정리해서 보여준다.
|
# (2) 태그마다 줄을 나눠 정리해서 보여준다.
|
||||||
# 저장할 때 같은 정리를 거친 값을 카페24에 쓴다(화면과 저장값이 같다).
|
# 저장할 때 같은 정리를 거친 값을 카페24에 쓴다(화면과 저장값이 같다).
|
||||||
"html_pc": store.format_html(store.decode_html_urls(desc.description)) if desc else "",
|
# 값은 카페24 GET 그대로가 아니라 읽기 지연을 보정한 **유효 현재값**이다.
|
||||||
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로).
|
"html_pc": store.format_html(store.decode_html_urls(html_effective)) if desc else "",
|
||||||
"fingerprint": store.fingerprint(desc.description) if desc else "",
|
# 지문은 **인코딩된 유효 현재값**으로 만든다(적용 직전 같은 규칙으로 계산한
|
||||||
|
# 유효 현재값과 비교하므로).
|
||||||
|
"fingerprint": store.fingerprint(html_effective) if desc else "",
|
||||||
|
"sync_state": sync_state,
|
||||||
|
"sync_pending": sync_state == store.SYNC_PENDING,
|
||||||
|
"sync_external": sync_state == store.SYNC_EXTERNAL,
|
||||||
|
"scalar_stale": scalar_stale,
|
||||||
|
"last_write_at": _short_dt(last_write.get("created_at").isoformat() if last_write and last_write.get("created_at") else ""),
|
||||||
|
"last_write_by": (last_write or {}).get("created_by") or "",
|
||||||
"revisions": st.list_revisions(product_no, limit=20),
|
"revisions": st.list_revisions(product_no, limit=20),
|
||||||
"editor_error": error,
|
"editor_error": error,
|
||||||
|
"option_display_types": store.OPTION_DISPLAY_LABELS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@products_router.get("/", response_class=HTMLResponse)
|
@products_router.get("/", response_class=HTMLResponse)
|
||||||
def product_list(request: Request) -> HTMLResponse:
|
def product_list(request: Request) -> HTMLResponse:
|
||||||
"""2분할 화면. `selected` 가 있으면 오른쪽 편집기까지 서버에서 그린다."""
|
"""3분할 화면. `selected` 가 있으면 편집기·정보 패널까지 서버에서 그린다."""
|
||||||
from app.main import render_template # noqa: WPS433
|
from app.main import render_template # noqa: WPS433
|
||||||
|
|
||||||
checked = guard(request)
|
checked = guard(request)
|
||||||
@@ -213,7 +318,7 @@ def product_list(request: Request) -> HTMLResponse:
|
|||||||
ctx.update(
|
ctx.update(
|
||||||
{
|
{
|
||||||
"page_title": "카페24 상품관리",
|
"page_title": "카페24 상품관리",
|
||||||
"page_subtitle": "상품 상세페이지 조회·편집·예약",
|
"page_subtitle": "상품 상세페이지 조회·편집·예약 · 상품 정보 수정",
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
"total": total,
|
"total": total,
|
||||||
"shown": len(rows),
|
"shown": len(rows),
|
||||||
@@ -235,7 +340,11 @@ def product_list(request: Request) -> HTMLResponse:
|
|||||||
|
|
||||||
@products_router.get("/products/{product_no}/pane", response_class=HTMLResponse)
|
@products_router.get("/products/{product_no}/pane", response_class=HTMLResponse)
|
||||||
def product_pane(request: Request, product_no: int) -> HTMLResponse:
|
def product_pane(request: Request, product_no: int) -> HTMLResponse:
|
||||||
"""오른쪽 편집기 조각만 — 목록을 다시 그리지 않기 위해 JS 가 가져간다."""
|
"""편집기 + 정보 패널 조각 — 목록을 다시 그리지 않기 위해 JS 가 가져간다.
|
||||||
|
|
||||||
|
두 조각을 한 응답에 담는다(`_panes.html`). 카페24 상품 조회를 한 번만 하기
|
||||||
|
위해서다. JS 가 `[data-pane=editor]` / `[data-pane=side]` 로 나눠 끼운다.
|
||||||
|
"""
|
||||||
from app.main import render_template # noqa: WPS433
|
from app.main import render_template # noqa: WPS433
|
||||||
|
|
||||||
checked = guard(request)
|
checked = guard(request)
|
||||||
@@ -246,11 +355,11 @@ def product_pane(request: Request, product_no: int) -> HTMLResponse:
|
|||||||
ctx = base_ctx(request, user, active_tab="products")
|
ctx = base_ctx(request, user, active_tab="products")
|
||||||
ctx.update(_editor_ctx(st, product_no))
|
ctx.update(_editor_ctx(st, product_no))
|
||||||
ctx["list_query"] = _list_query(request)
|
ctx["list_query"] = _list_query(request)
|
||||||
return _no_store(render_template(request, "cafe24/_editor.html", ctx))
|
return _no_store(render_template(request, "cafe24/_panes.html", ctx))
|
||||||
|
|
||||||
|
|
||||||
# 카페24 상품명 최대 길이(API 문서 기준). 넘기면 카페24가 거절하므로 미리 막는다.
|
# 카페24 상품명 최대 길이(API 문서 기준). 넘기면 카페24가 거절하므로 미리 막는다.
|
||||||
NAME_MAX = 250
|
NAME_MAX = store.NAME_MAX
|
||||||
|
|
||||||
|
|
||||||
@products_router.post("/products/{product_no}/name")
|
@products_router.post("/products/{product_no}/name")
|
||||||
@@ -263,7 +372,7 @@ def product_rename(
|
|||||||
|
|
||||||
상세설명과 마찬가지로 **쓰기 전에 카페24의 현재값을 읽는다.** 여기서는 되돌릴
|
상세설명과 마찬가지로 **쓰기 전에 카페24의 현재값을 읽는다.** 여기서는 되돌릴
|
||||||
HTML 이 없으므로 revision 은 만들지 않고, 대신 이전 이름을 감사로그에 남긴다
|
HTML 이 없으므로 revision 은 만들지 않고, 대신 이전 이름을 감사로그에 남긴다
|
||||||
(되돌리려면 로그를 보고 다시 바꾼다).
|
(되돌리려면 로그를 보고 다시 바꾼다). 현재값은 읽기 지연을 보정한 값이다.
|
||||||
"""
|
"""
|
||||||
st, user = require_store(request)
|
st, user = require_store(request)
|
||||||
actor = str(user.get("email") or "")
|
actor = str(user.get("email") or "")
|
||||||
@@ -276,7 +385,7 @@ def product_rename(
|
|||||||
|
|
||||||
api = build_cafe24_api(st)
|
api = build_cafe24_api(st)
|
||||||
try:
|
try:
|
||||||
current = products.get_product(api.client, product_no)
|
current, _ = load_product(st, api, product_no)
|
||||||
except Cafe24Error as exc:
|
except Cafe24Error as exc:
|
||||||
st.log_audit(
|
st.log_audit(
|
||||||
actor=actor, action="rename_product", product_no=product_no,
|
actor=actor, action="rename_product", product_no=product_no,
|
||||||
@@ -298,9 +407,7 @@ def product_rename(
|
|||||||
logger.warning("카페24 상품 %s 이름 변경 실패: %s", product_no, exc)
|
logger.warning("카페24 상품 %s 이름 변경 실패: %s", product_no, exc)
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
|
||||||
info = products.normalize_product(updated) if updated else {}
|
info = remember_write(st, product_no, updated)
|
||||||
if info.get("product_no"):
|
|
||||||
st.upsert_products([info])
|
|
||||||
after = str(info.get("product_name") or name)
|
after = str(info.get("product_name") or name)
|
||||||
st.log_audit(
|
st.log_audit(
|
||||||
actor=actor, action="rename_product", product_no=product_no,
|
actor=actor, action="rename_product", product_no=product_no,
|
||||||
@@ -348,14 +455,12 @@ def product_status(
|
|||||||
# 응답이 상품 dict 면 그것이 곧 현재 상태다. 모양이 다르면(방어) 다시 조회한다.
|
# 응답이 상품 dict 면 그것이 곧 현재 상태다. 모양이 다르면(방어) 다시 조회한다.
|
||||||
if "display" not in updated or "selling" not in updated:
|
if "display" not in updated or "selling" not in updated:
|
||||||
try:
|
try:
|
||||||
updated = products.get_product(api.client, product_no)
|
updated, _ = load_product(st, api, product_no)
|
||||||
except Cafe24Error as exc: # 쓰기는 됐다 — 화면만 요청값으로 맞춘다.
|
except Cafe24Error as exc: # 쓰기는 됐다 — 화면만 요청값으로 맞춘다.
|
||||||
logger.warning("카페24 상품 %s 상태 재조회 실패: %s", product_no, exc)
|
logger.warning("카페24 상품 %s 상태 재조회 실패: %s", product_no, exc)
|
||||||
updated = {}
|
updated = {}
|
||||||
|
|
||||||
info = products.normalize_product(updated) if updated else {}
|
info = remember_write(st, product_no, updated)
|
||||||
if info.get("product_no"):
|
|
||||||
st.upsert_products([info])
|
|
||||||
state = {
|
state = {
|
||||||
"display": bool(info.get("display", want if field == "display" else True)),
|
"display": bool(info.get("display", want if field == "display" else True)),
|
||||||
"selling": bool(info.get("selling", want if field == "selling" else True)),
|
"selling": bool(info.get("selling", want if field == "selling" else True)),
|
||||||
@@ -370,7 +475,7 @@ def product_status(
|
|||||||
|
|
||||||
@products_router.get("/products/{product_no}")
|
@products_router.get("/products/{product_no}")
|
||||||
def product_redirect(request: Request, product_no: int):
|
def product_redirect(request: Request, product_no: int):
|
||||||
"""옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
|
"""옛 단독 화면 주소 → 3분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
|
||||||
return RedirectResponse(url=f"/cafe24/?selected={product_no}", status_code=303)
|
return RedirectResponse(url=f"/cafe24/?selected={product_no}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@@ -387,9 +492,11 @@ def product_apply(
|
|||||||
|
|
||||||
순서를 지키는 것이 이 함수의 핵심이다.
|
순서를 지키는 것이 이 함수의 핵심이다.
|
||||||
1) 카페24에서 **현재** HTML 을 다시 읽는다(로컬 값을 현재값으로 믿지 않는다)
|
1) 카페24에서 **현재** HTML 을 다시 읽는다(로컬 값을 현재값으로 믿지 않는다)
|
||||||
2) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단
|
2) 읽기 지연을 판정해 **유효 현재값**을 정한다(예전 값을 돌려주는 중이면
|
||||||
3) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다
|
우리 마지막 쓰기가 현재값이다)
|
||||||
4) 쓰고, MANUAL 버전과 감사로그를 남긴다
|
3) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단
|
||||||
|
4) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다
|
||||||
|
5) 쓰고, 스냅샷·MANUAL 버전·감사로그를 남긴다
|
||||||
|
|
||||||
PC/모바일을 구분하지 않는다 — 두 필드에 같은 HTML 을 쓴다. 분리 사용 상품의
|
PC/모바일을 구분하지 않는다 — 두 필드에 같은 HTML 을 쓴다. 분리 사용 상품의
|
||||||
모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 그 내용도 BACKUP 으로 남긴다.
|
모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 그 내용도 BACKUP 으로 남긴다.
|
||||||
@@ -423,11 +530,15 @@ def product_apply(
|
|||||||
)
|
)
|
||||||
return RedirectResponse(url=f"{back}&err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303)
|
return RedirectResponse(url=f"{back}&err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303)
|
||||||
|
|
||||||
|
# 읽기 지연 판정 — 카페24가 아직 예전 값을 돌려주면 우리 마지막 쓰기가 현재값이다.
|
||||||
|
effective, sync_state, _ = resolve_description(st, product_no, current.description)
|
||||||
|
pending = sync_state == store.SYNC_PENDING
|
||||||
|
|
||||||
backup_id = st.add_revision(
|
backup_id = st.add_revision(
|
||||||
product_no=product_no,
|
product_no=product_no,
|
||||||
html_content=current.description,
|
html_content=effective,
|
||||||
revision_type=store.REVISION_BACKUP,
|
revision_type=store.REVISION_BACKUP,
|
||||||
memo="적용 직전 자동 백업",
|
memo="적용 직전 자동 백업" + (" (카페24 읽기 지연 — 마지막 적용값 기준)" if pending else ""),
|
||||||
created_by=actor,
|
created_by=actor,
|
||||||
)
|
)
|
||||||
# 모바일 내용이 PC 와 달랐다면 그것도 따로 남긴다. 아래에서 모바일을 PC 와 같게
|
# 모바일 내용이 PC 와 달랐다면 그것도 따로 남긴다. 아래에서 모바일을 PC 와 같게
|
||||||
@@ -441,7 +552,7 @@ def product_apply(
|
|||||||
created_by=actor,
|
created_by=actor,
|
||||||
)
|
)
|
||||||
|
|
||||||
if base_fingerprint and base_fingerprint != store.fingerprint(current.description):
|
if base_fingerprint and base_fingerprint != store.fingerprint(effective):
|
||||||
st.log_audit(
|
st.log_audit(
|
||||||
actor=actor, action="apply_description", product_no=product_no,
|
actor=actor, action="apply_description", product_no=product_no,
|
||||||
revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨",
|
revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨",
|
||||||
@@ -451,13 +562,13 @@ def product_apply(
|
|||||||
status_code=303,
|
status_code=303,
|
||||||
)
|
)
|
||||||
|
|
||||||
if submitted == current.description:
|
if submitted == effective:
|
||||||
return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
|
return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
|
||||||
try:
|
try:
|
||||||
# mobile_description 은 보내지 않는다 — update_descriptions 가
|
# mobile_description 은 보내지 않는다 — update_descriptions 가
|
||||||
# separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제하고
|
# separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제하고
|
||||||
# 카페24가 모바일 값을 자동으로 맞춰준다(모바일도 항상 PC와 같다).
|
# 카페24가 모바일 값을 자동으로 맞춰준다(모바일도 항상 PC와 같다).
|
||||||
products.update_descriptions(api.client, product_no, description=submitted)
|
updated = products.update_descriptions(api.client, product_no, description=submitted)
|
||||||
except Cafe24Error as exc:
|
except Cafe24Error as exc:
|
||||||
st.log_audit(
|
st.log_audit(
|
||||||
actor=actor, action="apply_description", product_no=product_no,
|
actor=actor, action="apply_description", product_no=product_no,
|
||||||
@@ -469,11 +580,11 @@ def product_apply(
|
|||||||
status_code=303,
|
status_code=303,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 카페24 관리자 API 는 쓰기 직후 몇 초간 이전 값을 돌려줄 때가 있다(쇼핑몰
|
# PUT 응답은 쓰기 직후의 실제 값 — 스냅샷으로 남긴다(상품명·가격·updated_date 등).
|
||||||
# 화면에는 바로 반영됨). 여기서 짧게 확인해, 화면으로 돌아갔을 때 우리
|
remember_write(st, product_no, updated if isinstance(updated, dict) else {})
|
||||||
# 편집기에도 이미 새 값이 보이게 한다(실패해도 적용 자체는 이미 끝났다).
|
|
||||||
products.wait_for_description(api.client, product_no, submitted)
|
|
||||||
|
|
||||||
|
# MANUAL revision 을 **먼저** 남긴다. 이것이 "마지막 쓰기" 기준이 되어, 카페24 GET 이
|
||||||
|
# 한동안 예전 값을 돌려줘도 편집기는 방금 적용한 내용을 보여준다.
|
||||||
revision_id = st.add_revision(
|
revision_id = st.add_revision(
|
||||||
product_no=product_no,
|
product_no=product_no,
|
||||||
html_content=submitted,
|
html_content=submitted,
|
||||||
@@ -481,13 +592,23 @@ def product_apply(
|
|||||||
memo=memo,
|
memo=memo,
|
||||||
created_by=actor,
|
created_by=actor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 카페24 관리자 API 는 쓰기 직후 몇 초간 이전 값을 돌려줄 때가 있다(쇼핑몰
|
||||||
|
# 화면에는 바로 반영됨). 여기서 짧게 확인만 한다 — 확인이 안 돼도 화면은 위
|
||||||
|
# MANUAL revision 을 기준으로 그리므로 문제없다.
|
||||||
|
confirmed = products.wait_for_description(api.client, product_no, submitted)
|
||||||
|
|
||||||
st.log_audit(
|
st.log_audit(
|
||||||
actor=actor, action="apply_description", product_no=product_no,
|
actor=actor, action="apply_description", product_no=product_no,
|
||||||
revision_id=revision_id, result="SUCCESS",
|
revision_id=revision_id, result="SUCCESS",
|
||||||
detail=f"{len(submitted)}자 적용 (백업 {backup_id}, 모바일 PC와 동일 유지)",
|
detail=(
|
||||||
|
f"{len(submitted)}자 적용 (백업 {backup_id}, 모바일 PC와 동일 유지, "
|
||||||
|
f"카페24 재조회 {'확인' if confirmed else '지연 — 마지막 적용값 표시'})"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
|
logger.info("카페24 상품 %s 상세설명 적용 (%s, 재조회 확인=%s)", product_no, actor, confirmed)
|
||||||
|
note = "" if confirmed else " 카페24 관리자 API 반영은 잠시 늦을 수 있어 방금 적용한 내용을 표시합니다."
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.",
|
url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.{note}",
|
||||||
status_code=303,
|
status_code=303,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -439,3 +439,352 @@ def fingerprint(html: str) -> str:
|
|||||||
순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다.
|
순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다.
|
||||||
"""
|
"""
|
||||||
return hashlib.sha256((html or "").encode("utf-8")).hexdigest()[:32]
|
return hashlib.sha256((html or "").encode("utf-8")).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 카페24 읽기 지연(read-after-write lag) 보정
|
||||||
|
#
|
||||||
|
# 실물 관찰: PUT 이 성공하고 쇼핑몰 화면에는 바로 반영되는데도, 관리자 API
|
||||||
|
# (`GET /admin/products/{no}`)는 한동안 **직전 값**을 돌려준다. 몇 초로 끝날 때도
|
||||||
|
# 있고 훨씬 길 때도 있다. 우리 쪽에는 캐시가 없으므로(no-store) 그 값을 그대로
|
||||||
|
# 보여주면 "적용했는데 예전 소스가 보이는" 증상이 된다. 더 나쁜 것은 그 예전 값으로
|
||||||
|
# 지문을 만들어 다음 적용 때 충돌로 오판하거나, 예전 값을 백업으로 남기는 것이다.
|
||||||
|
#
|
||||||
|
# 규칙: **마지막 쓰기가 권위다.**
|
||||||
|
# - 카페24 값이 마지막 쓰기와 같다 → synced (따라잡음)
|
||||||
|
# - 카페24 값이 우리가 아는 *과거 값* 중 하나 → pending (읽기 지연 — 마지막 쓰기를 보여준다)
|
||||||
|
# (최근 유예시간 안의 BACKUP/MANUAL/... revision 해시와 대조)
|
||||||
|
# - 카페24 값이 우리가 모르는 값 → external (관리자에서 직접 고침 — 카페24 값을 믿는다)
|
||||||
|
# - 최근 쓰기가 없다 → none (카페24 값 그대로)
|
||||||
|
# 유예시간(grace)이 지나면 무조건 카페24 값을 믿는다 — 지연은 영원하지 않고,
|
||||||
|
# 우리가 영원히 로컬 값을 고집하면 그것이 또 다른 캐시가 된다.
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
SYNC_SYNCED = "synced"
|
||||||
|
SYNC_PENDING = "pending"
|
||||||
|
SYNC_EXTERNAL = "external"
|
||||||
|
SYNC_NONE = "none"
|
||||||
|
|
||||||
|
# 쓰기 종류 — 이 revision 들은 "우리가 카페24에 올린 값"이다.
|
||||||
|
WRITE_REVISION_TYPES: tuple[str, ...] = (REVISION_MANUAL, REVISION_SCHEDULED, REVISION_ROLLBACK)
|
||||||
|
|
||||||
|
# 읽기 지연 유예시간 기본값(분). 환경변수 CAFE24_READ_LAG_GRACE_MIN 로 조정.
|
||||||
|
DEFAULT_READ_LAG_GRACE_MIN = 360
|
||||||
|
|
||||||
|
|
||||||
|
def parse_grace_minutes(value: object, default: int = DEFAULT_READ_LAG_GRACE_MIN) -> int:
|
||||||
|
try:
|
||||||
|
minutes = int(str(value or "").strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return minutes if minutes > 0 else default
|
||||||
|
|
||||||
|
|
||||||
|
def _as_aware(value: object) -> datetime | None:
|
||||||
|
"""ISO 문자열/naive datetime → KST aware datetime. 못 읽으면 None."""
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value if value.tzinfo else value.replace(tzinfo=KST)
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=KST)
|
||||||
|
|
||||||
|
|
||||||
|
def within_grace(written_at: object, *, grace_minutes: int, now: datetime | None = None) -> bool:
|
||||||
|
"""쓰기 시각이 유예시간 안인지."""
|
||||||
|
at = _as_aware(written_at)
|
||||||
|
if at is None:
|
||||||
|
return False
|
||||||
|
current = now or datetime.now(KST)
|
||||||
|
return current - at <= timedelta(minutes=max(1, int(grace_minutes)))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_description(
|
||||||
|
cafe24_html: str,
|
||||||
|
*,
|
||||||
|
last_write: dict | None,
|
||||||
|
known_digests: set[str] | frozenset[str],
|
||||||
|
grace_minutes: int,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""(화면·적용에 쓸 유효 HTML, 상태) 를 돌려준다.
|
||||||
|
|
||||||
|
last_write: 최근 쓰기 revision — {"html_content", "created_at"}.
|
||||||
|
known_digests: 유예시간 안의 revision 들의 내용 해시(`content_digest()` 결과) 집합.
|
||||||
|
"""
|
||||||
|
if not last_write:
|
||||||
|
return cafe24_html, SYNC_NONE
|
||||||
|
if not within_grace(last_write.get("created_at"), grace_minutes=grace_minutes, now=now):
|
||||||
|
return cafe24_html, SYNC_NONE
|
||||||
|
|
||||||
|
last_html = str(last_write.get("html_content") or "")
|
||||||
|
if cafe24_html == last_html:
|
||||||
|
return cafe24_html, SYNC_SYNCED
|
||||||
|
if content_digest(cafe24_html) in known_digests:
|
||||||
|
return last_html, SYNC_PENDING
|
||||||
|
return cafe24_html, SYNC_EXTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
def content_digest(html: str) -> str:
|
||||||
|
"""revision 내용 대조용 md5 hex — DB 의 `md5(html_content)` 와 같은 값.
|
||||||
|
|
||||||
|
(`fingerprint` 는 낙관적 잠금용 sha256 이고, 이것은 "우리가 아는 값인가" 대조용이다.
|
||||||
|
md5 는 모든 PostgreSQL 에 내장돼 있어 DB 쪽에서 계산할 수 있다.)
|
||||||
|
"""
|
||||||
|
return hashlib.md5((html or "").encode("utf-8")).hexdigest() # noqa: S324 — 보안 목적 아님
|
||||||
|
|
||||||
|
|
||||||
|
# 쓰기 직후 스냅샷에 보관하고, 지연 판정 시 덮어씌우는 스칼라 필드.
|
||||||
|
# description 은 넣지 않는다(크기 — revision 이 담당).
|
||||||
|
SNAPSHOT_FIELDS: tuple[str, ...] = (
|
||||||
|
"product_name",
|
||||||
|
"product_code",
|
||||||
|
"price",
|
||||||
|
"supply_price",
|
||||||
|
"retail_price",
|
||||||
|
"display",
|
||||||
|
"selling",
|
||||||
|
"detail_image",
|
||||||
|
"list_image",
|
||||||
|
"tiny_image",
|
||||||
|
"small_image",
|
||||||
|
"updated_date",
|
||||||
|
"separated_mobile_description",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def product_snapshot(product: dict) -> dict:
|
||||||
|
"""PUT 응답(상품 dict) → 스냅샷(JSONB 저장용). 값은 문자열/숫자/불리언만 남긴다."""
|
||||||
|
out: dict = {}
|
||||||
|
for key in SNAPSHOT_FIELDS:
|
||||||
|
if key in product and product[key] is not None:
|
||||||
|
value = product[key]
|
||||||
|
out[key] = value if isinstance(value, (bool, int, float)) else str(value)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def overlay_recent_write(
|
||||||
|
fetched: dict,
|
||||||
|
*,
|
||||||
|
snapshot: dict | None,
|
||||||
|
written_at: object,
|
||||||
|
grace_minutes: int,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> tuple[dict, bool]:
|
||||||
|
"""카페24 GET 결과가 우리 마지막 쓰기보다 오래됐으면 스냅샷 값을 덮어씌운다.
|
||||||
|
|
||||||
|
판정 근거는 카페24 자신의 `updated_date` 다 — PUT 응답의 updated_date(스냅샷)
|
||||||
|
보다 GET 의 updated_date 가 **이전**이면 GET 이 아직 예전 레코드를 돌려주는
|
||||||
|
것이다. 우리 서버 시계와 비교하지 않으므로 시계 차이에 영향받지 않는다.
|
||||||
|
날짜가 없어 판정할 수 없으면 카페24 값을 그대로 둔다.
|
||||||
|
반환: (병합된 상품 dict, 지연 여부)
|
||||||
|
"""
|
||||||
|
if not snapshot:
|
||||||
|
return fetched, False
|
||||||
|
if not within_grace(written_at, grace_minutes=grace_minutes, now=now):
|
||||||
|
return fetched, False
|
||||||
|
fetched_at = _as_aware(fetched.get("updated_date"))
|
||||||
|
snap_at = _as_aware(snapshot.get("updated_date"))
|
||||||
|
if fetched_at is None or snap_at is None or fetched_at >= snap_at:
|
||||||
|
return fetched, False
|
||||||
|
merged = dict(fetched)
|
||||||
|
for key in SNAPSHOT_FIELDS:
|
||||||
|
if key in snapshot and key != "updated_date":
|
||||||
|
merged[key] = snapshot[key]
|
||||||
|
merged["updated_date"] = snapshot.get("updated_date") or fetched.get("updated_date")
|
||||||
|
return merged, True
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 기본 정보(상품명·가격) 입력 검증 — 오른쪽 정보 패널
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
NAME_MAX = 250
|
||||||
|
PRICE_MAX = 2_147_483_647
|
||||||
|
|
||||||
|
|
||||||
|
def parse_price(value: object, *, field: str = "가격") -> str | None:
|
||||||
|
"""'6,900' / '6900.00' / 6900 → '6900.00'. 빈 값이면 None(변경 안 함)."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip().replace(",", "").replace("원", "")
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
number = int(round(float(text)))
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"{field}은(는) 숫자여야 합니다.") from None
|
||||||
|
if number < 0 or number > PRICE_MAX:
|
||||||
|
raise ValueError(f"{field}은(는) 0 이상 {PRICE_MAX:,} 이하여야 합니다.")
|
||||||
|
return f"{number}.00"
|
||||||
|
|
||||||
|
|
||||||
|
def price_equal(a: object, b: object) -> bool:
|
||||||
|
"""카페24 '6900.00' 과 우리 '6900.00'/'6900' 을 같은 값으로 본다."""
|
||||||
|
|
||||||
|
def norm(v: object) -> str | None:
|
||||||
|
try:
|
||||||
|
return parse_price(v)
|
||||||
|
except ValueError:
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
return norm(a) == norm(b)
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 옵션/품목 payload — 순수 변환(검증)만. 전송은 integrations.products 가 한다.
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
OPTION_DISPLAY_TYPES: tuple[str, ...] = ("S", "P", "B", "R")
|
||||||
|
OPTION_DISPLAY_LABELS: dict[str, str] = {
|
||||||
|
"S": "셀렉트박스",
|
||||||
|
"P": "미리보기(이미지)",
|
||||||
|
"B": "텍스트버튼",
|
||||||
|
"R": "라디오버튼",
|
||||||
|
}
|
||||||
|
VARIANT_CODE_RE = re.compile(r"^[A-Z0-9]{12}$")
|
||||||
|
CUSTOM_CODE_MAX = 40
|
||||||
|
ADDITIONAL_AMOUNT_MAX = 2_147_483_647
|
||||||
|
|
||||||
|
|
||||||
|
def parse_option_values(raw: object) -> list[str]:
|
||||||
|
"""'빨강, 파랑\\n노랑' → ['빨강','파랑','노랑'] (중복·빈 값 제거, 순서 유지)."""
|
||||||
|
text = str(raw or "")
|
||||||
|
seen: list[str] = []
|
||||||
|
for part in re.split(r"[,\n]", text):
|
||||||
|
value = part.strip()
|
||||||
|
if value and value not in seen:
|
||||||
|
seen.append(value)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def build_create_options_request(
|
||||||
|
option_name: str, values: list[str], *, display_type: str = "S"
|
||||||
|
) -> dict:
|
||||||
|
name = (option_name or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("옵션명을 입력하세요.")
|
||||||
|
if not values:
|
||||||
|
raise ValueError("옵션값을 하나 이상 입력하세요.")
|
||||||
|
dtype = (display_type or "S").strip().upper()
|
||||||
|
if dtype not in OPTION_DISPLAY_TYPES:
|
||||||
|
dtype = "S"
|
||||||
|
return {
|
||||||
|
"has_option": "T",
|
||||||
|
"option_type": "T", # 조합형
|
||||||
|
"option_list_type": "S", # 조합 분리선택형
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"option_name": name,
|
||||||
|
"option_value": [{"option_text": v} for v in values],
|
||||||
|
"option_display_type": dtype,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_update_options_request(
|
||||||
|
original: list[dict], edited: list[dict], *, option_list_type: str = ""
|
||||||
|
) -> dict:
|
||||||
|
"""옵션명/옵션값(이름·이미지·색상·표시방식) 수정 요청.
|
||||||
|
|
||||||
|
카페24 PUT options 는 `original_options`(수정 전) 와 `options`(수정 후) 를
|
||||||
|
**같은 순서·같은 개수**로 받아 짝을 맞춘다. 옵션 항목 추가/삭제는 이 API 로
|
||||||
|
할 수 없으므로 개수가 다르면 거부한다.
|
||||||
|
original: GET 응답의 options 그대로. edited: 화면에서 보낸 같은 모양의 목록.
|
||||||
|
"""
|
||||||
|
if len(original) != len(edited):
|
||||||
|
raise ValueError("옵션 개수가 카페24와 다릅니다. 다시 읽은 뒤 수정하세요.")
|
||||||
|
orig_out: list[dict] = []
|
||||||
|
new_out: list[dict] = []
|
||||||
|
for o, e in zip(original, edited):
|
||||||
|
o_vals = list(o.get("option_value") or [])
|
||||||
|
e_vals = list(e.get("option_value") or [])
|
||||||
|
if len(o_vals) != len(e_vals):
|
||||||
|
raise ValueError(
|
||||||
|
f"옵션 「{o.get('option_name')}」 의 옵션값 개수가 카페24와 다릅니다. "
|
||||||
|
"옵션값 추가/삭제는 이 API 로 할 수 없습니다."
|
||||||
|
)
|
||||||
|
name = str(e.get("option_name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("옵션명은 비울 수 없습니다.")
|
||||||
|
orig_entry: dict = {
|
||||||
|
"option_name": str(o.get("option_name") or ""),
|
||||||
|
"option_value": [],
|
||||||
|
}
|
||||||
|
new_entry: dict = {"option_name": name, "option_value": []}
|
||||||
|
if o.get("option_code"):
|
||||||
|
orig_entry["option_code"] = o["option_code"]
|
||||||
|
new_entry["option_code"] = o["option_code"]
|
||||||
|
dtype = str(
|
||||||
|
e.get("option_display_type") or o.get("option_display_type") or ""
|
||||||
|
).strip().upper()
|
||||||
|
if dtype in OPTION_DISPLAY_TYPES:
|
||||||
|
new_entry["option_display_type"] = dtype
|
||||||
|
for ov, ev in zip(o_vals, e_vals):
|
||||||
|
text = str(ev.get("option_text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("옵션값 이름은 비울 수 없습니다.")
|
||||||
|
o_item: dict = {"option_text": str(ov.get("option_text") or "")}
|
||||||
|
n_item: dict = {"option_text": text}
|
||||||
|
if ov.get("value_no") is not None:
|
||||||
|
o_item["value_no"] = ov["value_no"]
|
||||||
|
n_item["value_no"] = ov["value_no"]
|
||||||
|
for key in ("option_image_file", "option_link_image", "option_color"):
|
||||||
|
value = ev.get(key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
value = str(value).strip()
|
||||||
|
if value:
|
||||||
|
n_item[key] = value
|
||||||
|
orig_entry["option_value"].append(o_item)
|
||||||
|
new_entry["option_value"].append(n_item)
|
||||||
|
orig_out.append(orig_entry)
|
||||||
|
new_out.append(new_entry)
|
||||||
|
request: dict = {"original_options": orig_out, "options": new_out}
|
||||||
|
list_type = (option_list_type or "").strip().upper()
|
||||||
|
if list_type in ("S", "C"):
|
||||||
|
request["option_list_type"] = list_type
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
def parse_additional_amount(value: object) -> str | None:
|
||||||
|
"""'1,000' / '-500' → '1000.00'. 빈 값은 None(변경 안 함)."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip().replace(",", "").replace("원", "")
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
number = int(round(float(text)))
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("추가금액은 숫자여야 합니다.") from None
|
||||||
|
if abs(number) > ADDITIONAL_AMOUNT_MAX:
|
||||||
|
raise ValueError("추가금액 범위를 벗어났습니다.")
|
||||||
|
return f"{number}.00"
|
||||||
|
|
||||||
|
|
||||||
|
def build_variant_updates(rows: list[dict]) -> list[dict]:
|
||||||
|
"""화면 행 목록 → PUT /variants `requests` 배열. 바뀔 것이 없는 행은 뺀다."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for row in rows:
|
||||||
|
code = str(row.get("variant_code") or "").strip().upper()
|
||||||
|
if not VARIANT_CODE_RE.match(code):
|
||||||
|
raise ValueError(f"품목코드 형식이 올바르지 않습니다: {code or '(빈 값)'}")
|
||||||
|
item: dict = {"variant_code": code}
|
||||||
|
if row.get("custom_variant_code") is not None:
|
||||||
|
custom = str(row["custom_variant_code"]).strip()
|
||||||
|
if len(custom) > CUSTOM_CODE_MAX:
|
||||||
|
raise ValueError(f"자체 품목코드는 {CUSTOM_CODE_MAX}자를 넘을 수 없습니다.")
|
||||||
|
item["custom_variant_code"] = custom
|
||||||
|
if "additional_amount" in row:
|
||||||
|
amount = parse_additional_amount(row["additional_amount"])
|
||||||
|
if amount is not None:
|
||||||
|
item["additional_amount"] = amount
|
||||||
|
for flag in ("display", "selling"):
|
||||||
|
if row.get(flag) is not None:
|
||||||
|
item[flag] = "T" if parse_tristate(row[flag]) else "F"
|
||||||
|
if len(item) > 1:
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{# 오른쪽 편집기 조각.
|
{# 가운데 편집기 조각.
|
||||||
전체 페이지(products.html)가 include 하고, JS 가 /products/{no}/pane 으로
|
전체 페이지(products.html)가 include 하고, JS 가 /products/{no}/pane 으로
|
||||||
같은 조각만 다시 받아 끼워 넣는다. 그래서 여기에는 <script> 를 두지 않는다
|
같은 조각(+ 오른쪽 정보 패널 _side.html)을 다시 받아 끼워 넣는다. 그래서 여기에는 <script> 를 두지 않는다
|
||||||
(innerHTML 로 삽입된 script 는 실행되지 않는다 — JS 는 products.html 에 있고
|
(innerHTML 로 삽입된 script 는 실행되지 않는다 — JS 는 products.html 에 있고
|
||||||
삽입 후 cf24BindEditor() 로 다시 연결한다). #}
|
삽입 후 cf24BindEditor() 로 다시 연결한다). #}
|
||||||
|
|
||||||
@@ -11,6 +11,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# 읽기 지연 배너 — 카페24 관리자 API 가 아직 이전 값을 돌려주는 동안에는 마지막으로
|
||||||
|
적용한 내용(MANUAL/SCHEDULED revision)을 편집기에 보여준다. 쇼핑몰 화면에는 이미
|
||||||
|
반영돼 있다(실물 관찰). 지문도 이 값으로 만들어 다음 적용이 충돌로 오판되지 않는다. #}
|
||||||
|
{% if sync_pending %}
|
||||||
|
<div class="cf24-flash cf24-flash-warn" id="cf24-sync-banner">
|
||||||
|
<strong>카페24 반영 대기 중</strong> — 카페24 관리자 API 가 아직 이전 소스를 돌려주고 있어
|
||||||
|
마지막으로 적용한 내용{% if last_write_at %}({{ last_write_at }}{% if last_write_by %}, {{ last_write_by }}{% endif %}){% endif %}을 표시합니다.
|
||||||
|
쇼핑몰 화면에는 이미 반영되어 있습니다.
|
||||||
|
<button type="button" class="erp-btn erp-btn-outline cf24-btn-inline" data-reload="{{ product_no }}">다시 확인</button>
|
||||||
|
</div>
|
||||||
|
{% elif sync_external %}
|
||||||
|
<div class="cf24-flash cf24-flash-warn" id="cf24-sync-banner">
|
||||||
|
이 상품은 최근 여기서 적용한 뒤 <strong>카페24 관리자에서 직접 수정</strong>된 것으로 보입니다.
|
||||||
|
카페24의 현재 소스를 표시합니다.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="cf24-editor-head">
|
<div class="cf24-editor-head">
|
||||||
{# 제목 = 상품명. 연필 버튼을 누르면 입력칸으로 바뀐다(평소에는 읽기 전용 —
|
{# 제목 = 상품명. 연필 버튼을 누르면 입력칸으로 바뀐다(평소에는 읽기 전용 —
|
||||||
클릭만으로 실수로 고쳐지지 않게). 저장은 JS 가 POST /products/{no}/name.
|
클릭만으로 실수로 고쳐지지 않게). 저장은 JS 가 POST /products/{no}/name.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{# 상품 선택 시 JS 가 가져가는 조각 묶음 — 가운데(편집기) + 오른쪽(정보 패널).
|
||||||
|
한 응답에 둘을 담아 카페24 상품 조회를 한 번만 한다. products.html 의 select()
|
||||||
|
가 [data-pane] 별로 나눠 각 칸에 끼워 넣는다. 여기에 <script> 를 두지 않는다
|
||||||
|
(innerHTML 로 삽입된 script 는 실행되지 않는다). #}
|
||||||
|
<div data-pane="editor">{% include "cafe24/_editor.html" %}</div>
|
||||||
|
<div data-pane="side">{% include "cafe24/_side.html" %}</div>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{# 오른쪽 상품 정보 패널 조각.
|
||||||
|
기본 정보(상품명·판매가·공급가·소비자가) · 대표 이미지 · 옵션/품목.
|
||||||
|
전체 페이지(products.html)가 include 하고, 상품을 바꾸면 JS 가 /products/{no}/pane
|
||||||
|
에서 같은 조각을 다시 받아 끼워 넣는다. 그래서 <script> 를 두지 않는다 — JS 는
|
||||||
|
products.html 의 cf24BindSide() 에 있다. 쓰기는 전부 routes_product_info.py 의
|
||||||
|
JSON API 로 하고, 화면은 **응답값**으로 다시 그린다(요청값을 낙관 반영하지 않는다). #}
|
||||||
|
|
||||||
|
{% if desc %}
|
||||||
|
<div class="cf24-side" id="cf24-side" data-product-no="{{ product_no }}">
|
||||||
|
|
||||||
|
{# ── 기본 정보 ── #}
|
||||||
|
<section class="cf24-side-sec">
|
||||||
|
<h4 class="cf24-side-title">기본 정보</h4>
|
||||||
|
{% if scalar_stale %}
|
||||||
|
<p class="cf24-side-note cf24-side-note-warn">
|
||||||
|
카페24 관리자 API 가 아직 이전 값을 돌려주고 있어 마지막으로 적용한 값을 표시합니다.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<form class="cf24-side-form" id="cf24-basic-form" autocomplete="off">
|
||||||
|
<label class="cf24-side-field">
|
||||||
|
<span>상품명</span>
|
||||||
|
<input type="text" name="product_name" maxlength="250" value="{{ info.product_name }}" />
|
||||||
|
</label>
|
||||||
|
<div class="cf24-side-grid3">
|
||||||
|
<label class="cf24-side-field">
|
||||||
|
<span>판매가</span>
|
||||||
|
<input type="text" name="price" inputmode="numeric" value="{{ info.price_plain }}" placeholder="원" />
|
||||||
|
</label>
|
||||||
|
<label class="cf24-side-field">
|
||||||
|
<span>공급가</span>
|
||||||
|
<input type="text" name="supply_price" inputmode="numeric" value="{{ info.supply_price_plain }}" placeholder="원" />
|
||||||
|
</label>
|
||||||
|
<label class="cf24-side-field">
|
||||||
|
<span>소비자가</span>
|
||||||
|
<input type="text" name="retail_price" inputmode="numeric" value="{{ info.retail_price_plain }}" placeholder="원" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="cf24-side-actions">
|
||||||
|
<span class="cf24-muted" id="cf24-basic-msg"></span>
|
||||||
|
<button class="erp-btn erp-btn-primary" type="submit" id="cf24-basic-save">카페24에 저장</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# ── 대표 이미지 ── #}
|
||||||
|
<section class="cf24-side-sec">
|
||||||
|
<h4 class="cf24-side-title">대표 이미지</h4>
|
||||||
|
<div class="cf24-image-box" id="cf24-image-box">
|
||||||
|
{% if info.detail_image %}
|
||||||
|
<a href="{{ info.detail_image }}" target="_blank" rel="noopener noreferrer" id="cf24-image-link">
|
||||||
|
<img class="cf24-image-main" id="cf24-image-main" src="{{ info.detail_image }}" alt="대표 이미지" />
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="#" target="_blank" rel="noopener noreferrer" id="cf24-image-link" class="is-hidden">
|
||||||
|
<img class="cf24-image-main" id="cf24-image-main" src="" alt="대표 이미지" />
|
||||||
|
</a>
|
||||||
|
<div class="cf24-image-empty" id="cf24-image-empty">등록된 대표 이미지가 없습니다.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="cf24-image-thumbs" id="cf24-image-thumbs">
|
||||||
|
{# 이미지가 없는 종류는 칸째로 숨긴다(빈 칸에 이름만 남으면 어색하다).
|
||||||
|
업로드 뒤 응답에 경로가 오면 JS 가 칸을 다시 보인다. #}
|
||||||
|
{% for key, label in (("list_image", "목록"), ("tiny_image", "작은목록"), ("small_image", "축소")) %}
|
||||||
|
<figure class="cf24-image-thumb{% if not info[key] %} is-hidden{% endif %}" data-image-figure="{{ key }}">
|
||||||
|
<img src="{{ info[key] }}" alt="{{ label }}" data-image-key="{{ key }}" />
|
||||||
|
<figcaption>{{ label }}</figcaption>
|
||||||
|
</figure>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<form class="cf24-side-form" id="cf24-image-form">
|
||||||
|
<label class="cf24-file-pick">
|
||||||
|
<input type="file" name="file" id="cf24-image-file" accept="image/jpeg,image/png,image/gif,image/webp" />
|
||||||
|
<span class="erp-btn erp-btn-outline">파일 선택</span>
|
||||||
|
<span class="cf24-file-name" id="cf24-image-filename">선택된 파일 없음</span>
|
||||||
|
</label>
|
||||||
|
<div class="cf24-side-actions">
|
||||||
|
<span class="cf24-muted" id="cf24-image-msg">JPG·PNG·GIF·WEBP, 10MB 이하. 목록/축소 이미지는 카페24가 자동 생성합니다.</span>
|
||||||
|
<button class="erp-btn erp-btn-primary" type="submit" id="cf24-image-save" disabled>업로드하여 교체</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# ── 옵션 / 품목 — 펼칠 때 JSON 으로 불러온다(카페24 호출 2회) ── #}
|
||||||
|
<details class="cf24-side-sec cf24-side-details" id="cf24-options-details">
|
||||||
|
<summary class="cf24-side-title">옵션 / 품목 <span class="cf24-muted" id="cf24-options-count"></span></summary>
|
||||||
|
<div class="cf24-options-body" id="cf24-options-body">
|
||||||
|
<p class="cf24-muted">불러오는 중…</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="cf24-side cf24-side-empty">
|
||||||
|
<p class="cf24-muted">카페24 조회에 실패해 상품 정보를 표시할 수 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -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=20260819c" />
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260918a" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="cf24-split">
|
<div class="cf24-split cf24-split-3">
|
||||||
|
|
||||||
{# ── 왼쪽: 상품 목록 ──────────────────────────────────────── #}
|
{# ── 왼쪽: 상품 목록 ──────────────────────────────────────── #}
|
||||||
<aside class="erp-card cf24-pane cf24-pane-list">
|
<aside class="erp-card cf24-pane cf24-pane-list">
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{# ── 오른쪽: 상세페이지 편집 ──────────────────────────────── #}
|
{# ── 가운데: 상세페이지 편집 ──────────────────────────────── #}
|
||||||
<section class="erp-card cf24-pane cf24-pane-editor" id="cf24-editor-pane">
|
<section class="erp-card cf24-pane cf24-pane-editor" id="cf24-editor-pane">
|
||||||
{% if selected %}
|
{% if selected %}
|
||||||
{% include "cafe24/_editor.html" %}
|
{% include "cafe24/_editor.html" %}
|
||||||
@@ -97,6 +97,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{# ── 오른쪽: 상품 정보 패널(상품명·가격 / 대표 이미지 / 옵션·품목) ── #}
|
||||||
|
<section class="erp-card cf24-pane cf24-pane-side" id="cf24-side-pane">
|
||||||
|
{% if selected %}
|
||||||
|
{% include "cafe24/_side.html" %}
|
||||||
|
{% else %}
|
||||||
|
<div class="cf24-empty-pane">
|
||||||
|
<p class="cf24-muted">상품을 선택하면 상품명·가격, 대표 이미지, 옵션/품목을 여기서 수정할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -374,15 +385,15 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 「다시 읽기」 — 카페24 관리자에서 방금 고친 경우 현재 소스를 강제로 다시 받는다.
|
// 「다시 읽기」/「다시 확인」 — 카페24 관리자에서 방금 고친 경우 현재 소스를 강제로
|
||||||
var reloadBtn = pane.querySelector("[data-reload]");
|
// 다시 받는다(도구 막대 버튼 + 반영 대기 배너 버튼, 둘 다 같은 동작).
|
||||||
if (reloadBtn) {
|
pane.querySelectorAll("[data-reload]").forEach(function (reloadBtn) {
|
||||||
reloadBtn.addEventListener("click", function () {
|
reloadBtn.addEventListener("click", function () {
|
||||||
confirmLeave().then(function (ok) {
|
confirmLeave().then(function (ok) {
|
||||||
if (ok) select(reloadBtn.dataset.reload, false);
|
if (ok) select(reloadBtn.dataset.reload, false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
// 예약 시각 — 네이티브 date/time input 은 투명하게 두고(opacity:0), 보이는
|
// 예약 시각 — 네이티브 date/time input 은 투명하게 두고(opacity:0), 보이는
|
||||||
// 글자는 우리가 원하는 형식으로 직접 그린다("2026년 08월 20일" / "오후 07시 30분").
|
// 글자는 우리가 원하는 형식으로 직접 그린다("2026년 08월 20일" / "오후 07시 30분").
|
||||||
@@ -647,6 +658,509 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════
|
||||||
|
오른쪽 상품 정보 패널 — 상품명·가격 / 대표 이미지 / 옵션·품목
|
||||||
|
쓰기는 전부 routes_product_info.py 의 JSON API. 화면은 **응답값**으로 그린다
|
||||||
|
(요청값을 낙관 반영하면 실패했을 때 카페24와 어긋난다). 카페24 GET 은 쓰기
|
||||||
|
직후 한동안 예전 값을 돌려주므로, 저장 뒤에 다시 GET 하지 않는다.
|
||||||
|
════════════════════════════════════════════════════════════ */
|
||||||
|
function escHtml(text) {
|
||||||
|
return String(text == null ? "" : text).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiJson(method, url, body) {
|
||||||
|
var opts = { method: method, credentials: "same-origin", cache: "no-store", headers: {} };
|
||||||
|
if (body instanceof FormData) {
|
||||||
|
opts.body = body;
|
||||||
|
} else if (body !== undefined) {
|
||||||
|
opts.headers["Content-Type"] = "application/json";
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
return fetch(url, opts).then(function (res) {
|
||||||
|
return res.json().catch(function () { return {}; }).then(function (data) {
|
||||||
|
if (!res.ok) throw new Error(data.detail || "HTTP " + res.status);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wonText(value) {
|
||||||
|
var n = parseInt(String(value || "").replace(/,/g, ""), 10);
|
||||||
|
return isNaN(n) ? "" : n.toLocaleString("ko-KR") + "원";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bust(url) {
|
||||||
|
// 같은 경로에 새 이미지가 올라가면 브라우저가 옛 그림을 재사용한다 — 시각을 붙인다.
|
||||||
|
if (!url) return "";
|
||||||
|
return url + (url.indexOf("?") === -1 ? "?" : "&") + "t=" + Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMsg(el, text, kind) {
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || "";
|
||||||
|
el.classList.remove("is-ok", "is-err");
|
||||||
|
if (kind) el.classList.add(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 입력칸의 값이 처음 값(defaultValue)과 다르면 표시한다.
|
||||||
|
function trackDirty(container) {
|
||||||
|
container.querySelectorAll("input[type='text'], select, textarea").forEach(function (el) {
|
||||||
|
var mark = function () {
|
||||||
|
var base = el.tagName === "SELECT" ? el.dataset.initial : el.defaultValue;
|
||||||
|
el.classList.toggle("is-dirty", el.value !== (base == null ? "" : base));
|
||||||
|
};
|
||||||
|
if (el.tagName === "SELECT" && el.dataset.initial == null) el.dataset.initial = el.value;
|
||||||
|
el.addEventListener("input", mark);
|
||||||
|
el.addEventListener("change", mark);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitValues(container) {
|
||||||
|
container.querySelectorAll("input[type='text'], select, textarea").forEach(function (el) {
|
||||||
|
if (el.tagName === "SELECT") el.dataset.initial = el.value;
|
||||||
|
else el.defaultValue = el.value;
|
||||||
|
el.classList.remove("is-dirty");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.cf24BindSide = function () {
|
||||||
|
var sideRoot = document.getElementById("cf24-side-pane");
|
||||||
|
var root = sideRoot && sideRoot.querySelector("#cf24-side");
|
||||||
|
if (!root) return;
|
||||||
|
var no = root.dataset.productNo;
|
||||||
|
|
||||||
|
// ── 기본 정보: 상품명 · 판매가 · 공급가 · 소비자가 ──
|
||||||
|
(function setupBasic() {
|
||||||
|
var form = root.querySelector("#cf24-basic-form");
|
||||||
|
if (!form) return;
|
||||||
|
var msg = form.querySelector("#cf24-basic-msg");
|
||||||
|
var btn = form.querySelector("#cf24-basic-save");
|
||||||
|
trackDirty(form);
|
||||||
|
|
||||||
|
function paintTitle(name) {
|
||||||
|
var text = document.getElementById("cf24-name-text");
|
||||||
|
if (text) text.textContent = name;
|
||||||
|
var input = document.getElementById("cf24-name-input");
|
||||||
|
if (input) input.value = name;
|
||||||
|
var tr = document.querySelector('#cf24-list tr.cf24-row[data-no="' + no + '"]');
|
||||||
|
if (!tr) return;
|
||||||
|
tr.dataset.name = name;
|
||||||
|
var cell = tr.querySelector(".cf24-col-name");
|
||||||
|
if (!cell) return;
|
||||||
|
cell.title = name;
|
||||||
|
var link = cell.querySelector("a");
|
||||||
|
(link || cell).textContent = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var payload = {};
|
||||||
|
var labels = { product_name: "상품명", price: "판매가", supply_price: "공급가", retail_price: "소비자가" };
|
||||||
|
var lines = [];
|
||||||
|
form.querySelectorAll("input[type='text']").forEach(function (el) {
|
||||||
|
if (el.value !== el.defaultValue) {
|
||||||
|
payload[el.name] = el.value.trim();
|
||||||
|
lines.push(labels[el.name] + ": " + (el.defaultValue || "(없음)") + " → " + (el.value.trim() || "(없음)"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!lines.length) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
|
||||||
|
if (payload.product_name === "") {
|
||||||
|
window.erpAlert("상품명은 비울 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.erpConfirm("다음 항목을 카페24에 바로 반영합니다. 계속할까요?\n\n" + lines.join("\n"))
|
||||||
|
.then(function (ok) {
|
||||||
|
if (!ok) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
setMsg(msg, "저장 중…", "");
|
||||||
|
apiJson("POST", "/cafe24/products/" + no + "/basic", payload)
|
||||||
|
.then(function (data) {
|
||||||
|
var p = data.product || {};
|
||||||
|
form.querySelector('[name="product_name"]').value = p.product_name || "";
|
||||||
|
form.querySelector('[name="price"]').value = p.price ? String(parseInt(p.price, 10)) : "";
|
||||||
|
form.querySelector('[name="supply_price"]').value = p.supply_price ? String(parseInt(p.supply_price, 10)) : "";
|
||||||
|
form.querySelector('[name="retail_price"]').value = p.retail_price ? String(parseInt(p.retail_price, 10)) : "";
|
||||||
|
commitValues(form);
|
||||||
|
if (p.product_name) paintTitle(p.product_name);
|
||||||
|
setMsg(msg, (data.changed && data.changed.length) ? "카페24에 반영했습니다." : "변경된 값이 없어 보내지 않았습니다.", "is-ok");
|
||||||
|
})
|
||||||
|
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); })
|
||||||
|
.then(function () { btn.disabled = false; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── 대표 이미지 ──
|
||||||
|
(function setupImage() {
|
||||||
|
var form = root.querySelector("#cf24-image-form");
|
||||||
|
if (!form) return;
|
||||||
|
var fileEl = form.querySelector("#cf24-image-file");
|
||||||
|
var nameEl = form.querySelector("#cf24-image-filename");
|
||||||
|
var msg = form.querySelector("#cf24-image-msg");
|
||||||
|
var btn = form.querySelector("#cf24-image-save");
|
||||||
|
var img = root.querySelector("#cf24-image-main");
|
||||||
|
var link = root.querySelector("#cf24-image-link");
|
||||||
|
var empty = root.querySelector("#cf24-image-empty");
|
||||||
|
|
||||||
|
function paint(p) {
|
||||||
|
if (p.detail_image && img) {
|
||||||
|
img.src = bust(p.detail_image);
|
||||||
|
if (link) { link.href = p.detail_image; link.classList.remove("is-hidden"); }
|
||||||
|
if (empty) empty.classList.add("is-hidden");
|
||||||
|
}
|
||||||
|
root.querySelectorAll("[data-image-key]").forEach(function (el) {
|
||||||
|
var v = p[el.dataset.imageKey];
|
||||||
|
if (!v) return;
|
||||||
|
el.src = bust(v);
|
||||||
|
var fig = el.closest("[data-image-figure]");
|
||||||
|
if (fig) fig.classList.remove("is-hidden");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fileEl.addEventListener("change", function () {
|
||||||
|
var f = fileEl.files && fileEl.files[0];
|
||||||
|
nameEl.textContent = f ? f.name + " (" + Math.round(f.size / 1024) + "KB)" : "선택된 파일 없음";
|
||||||
|
btn.disabled = !f;
|
||||||
|
if (f && f.size > 10 * 1024 * 1024) {
|
||||||
|
setMsg(msg, "10MB 를 넘는 파일은 올릴 수 없습니다.", "is-err");
|
||||||
|
btn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (f && img) {
|
||||||
|
// 업로드 전 미리보기 — 실제 교체는 응답을 받은 뒤 서버 경로로 다시 그린다.
|
||||||
|
img.src = URL.createObjectURL(f);
|
||||||
|
if (link) link.classList.remove("is-hidden");
|
||||||
|
if (empty) empty.classList.add("is-hidden");
|
||||||
|
setMsg(msg, "미리보기입니다. 「업로드하여 교체」를 누르면 카페24에 반영됩니다.", "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var f = fileEl.files && fileEl.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
window.erpConfirm("대표 이미지를 「" + f.name + "」(으)로 교체합니다.\n카페24 쇼핑몰에 바로 반영되며 목록/축소 이미지도 함께 바뀝니다. 계속할까요?")
|
||||||
|
.then(function (ok) {
|
||||||
|
if (!ok) return;
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append("file", f);
|
||||||
|
btn.disabled = true;
|
||||||
|
setMsg(msg, "업로드 중… (파일 크기에 따라 수십 초 걸릴 수 있습니다)", "");
|
||||||
|
apiJson("POST", "/cafe24/products/" + no + "/image", fd)
|
||||||
|
.then(function (data) {
|
||||||
|
paint(data.product || {});
|
||||||
|
fileEl.value = "";
|
||||||
|
nameEl.textContent = "선택된 파일 없음";
|
||||||
|
setMsg(msg, "카페24에 반영했습니다.", "is-ok");
|
||||||
|
})
|
||||||
|
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); btn.disabled = false; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── 옵션 / 품목 — 펼칠 때 불러온다 ──
|
||||||
|
(function setupOptions() {
|
||||||
|
var details = root.querySelector("#cf24-options-details");
|
||||||
|
var body = root.querySelector("#cf24-options-body");
|
||||||
|
var count = root.querySelector("#cf24-options-count");
|
||||||
|
if (!details || !body) return;
|
||||||
|
var loaded = false;
|
||||||
|
var state = { option: null, variants: [], displayTypes: {} };
|
||||||
|
|
||||||
|
details.addEventListener("toggle", function () {
|
||||||
|
if (details.open && !loaded) { loaded = true; load(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
body.innerHTML = '<p class="cf24-muted">불러오는 중…</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) {
|
||||||
|
loaded = false;
|
||||||
|
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", function () { loaded = true; 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 옵션 정의 편집
|
||||||
|
var html = '<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 thumb = v.option_image_file
|
||||||
|
? '<img class="cf24-opt-thumb" src="' + escHtml(v.option_image_file) + '" alt="" />'
|
||||||
|
: '<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>";
|
||||||
|
|
||||||
|
// 품목 표
|
||||||
|
html += '<div class="cf24-opt-group"><h5 class="cf24-side-title">품목 (' + variants.length + ')</h5>';
|
||||||
|
if (!variants.length) {
|
||||||
|
html += '<p class="cf24-muted">품목이 아직 조회되지 않았습니다. 잠시 뒤 「옵션 / 품목」을 접었다 펴서 다시 불러오세요.</p>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="cf24-variants-wrap"><table class="cf24-variants"><thead><tr>' +
|
||||||
|
"<th>옵션</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 data-code="' + escHtml(v.variant_code) + '">' +
|
||||||
|
'<td class="cf24-v-name" title="' + escHtml(v.variant_code) + '">' +
|
||||||
|
(v.image ? '<img class="cf24-opt-thumb" style="width:28px;height:28px;vertical-align:middle;margin-right:4px" src="' + escHtml(v.image) + '" alt="" />' : "") +
|
||||||
|
escHtml(label) + "</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 += '<div class="cf24-opt-danger"><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-delete">옵션 전체 삭제</button></div></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);
|
||||||
|
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("tr[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('tr[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 를
|
||||||
// 돌려주므로 호출부는 모두 then() 안에서 이어간다.
|
// 돌려주므로 호출부는 모두 then() 안에서 이어간다.
|
||||||
function confirmLeave() {
|
function confirmLeave() {
|
||||||
@@ -654,9 +1168,13 @@
|
|||||||
return window.erpConfirm("편집한 내용이 저장되지 않았습니다. 이동할까요?");
|
return window.erpConfirm("편집한 내용이 저장되지 않았습니다. 이동할까요?");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 목록 클릭 → 오른쪽만 교체 ──
|
// ── 목록 클릭 → 가운데(편집기)·오른쪽(정보 패널)만 교체 ──
|
||||||
|
// /pane 응답 하나에 두 조각이 담겨 온다([data-pane=editor] / [data-pane=side]).
|
||||||
|
var sidePane = document.getElementById("cf24-side-pane");
|
||||||
|
|
||||||
function select(no, push) {
|
function select(no, push) {
|
||||||
pane.innerHTML = '<p class="cf24-muted" style="padding:16px;">불러오는 중…</p>';
|
pane.innerHTML = '<p class="cf24-muted" style="padding:16px;">불러오는 중…</p>';
|
||||||
|
if (sidePane) sidePane.innerHTML = "";
|
||||||
var url = "/cafe24/products/" + no + "/pane" + (listQuery ? "?" + listQuery : "");
|
var url = "/cafe24/products/" + no + "/pane" + (listQuery ? "?" + listQuery : "");
|
||||||
// no-store: 캐시된 조각이 뜨면 카페24의 현재 소스가 아닌 예전 것을 편집하게 된다.
|
// no-store: 캐시된 조각이 뜨면 카페24의 현재 소스가 아닌 예전 것을 편집하게 된다.
|
||||||
fetch(url, { credentials: "same-origin", cache: "no-store" })
|
fetch(url, { credentials: "same-origin", cache: "no-store" })
|
||||||
@@ -667,9 +1185,16 @@
|
|||||||
})
|
})
|
||||||
.then(function (htmlText) {
|
.then(function (htmlText) {
|
||||||
if (htmlText === null) return;
|
if (htmlText === null) return;
|
||||||
pane.innerHTML = htmlText;
|
var holder = document.createElement("div");
|
||||||
|
holder.innerHTML = htmlText;
|
||||||
|
var editorPart = holder.querySelector('[data-pane="editor"]');
|
||||||
|
var sidePart = holder.querySelector('[data-pane="side"]');
|
||||||
|
pane.innerHTML = editorPart ? editorPart.innerHTML : htmlText;
|
||||||
|
if (sidePane) sidePane.innerHTML = sidePart ? sidePart.innerHTML : "";
|
||||||
window.cf24BindEditor();
|
window.cf24BindEditor();
|
||||||
|
window.cf24BindSide();
|
||||||
pane.scrollTop = 0;
|
pane.scrollTop = 0;
|
||||||
|
if (sidePane) sidePane.scrollTop = 0;
|
||||||
if (push) {
|
if (push) {
|
||||||
var target = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no;
|
var target = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no;
|
||||||
history.pushState({ no: no }, "", target);
|
history.pushState({ no: no }, "", target);
|
||||||
@@ -746,6 +1271,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (pane.querySelector(".cf24-editor-form")) window.cf24BindEditor();
|
if (pane.querySelector(".cf24-editor-form")) window.cf24BindEditor();
|
||||||
|
window.cf24BindSide();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -802,6 +802,10 @@ class _FakeStore:
|
|||||||
self.added.append(fields)
|
self.added.append(fields)
|
||||||
return 900 + len(self.added)
|
return 900 + len(self.added)
|
||||||
|
|
||||||
|
def save_write_snapshot(self, product_no, section, data):
|
||||||
|
self.snapshots = getattr(self, "snapshots", [])
|
||||||
|
self.snapshots.append((product_no, section, data))
|
||||||
|
|
||||||
def finish_schedule(self, schedule_id, *, status, error="", next_retry_at=None, retry_count=None):
|
def finish_schedule(self, schedule_id, *, status, error="", next_retry_at=None, retry_count=None):
|
||||||
self.finished.append(
|
self.finished.append(
|
||||||
{"id": schedule_id, "status": status, "error": error,
|
{"id": schedule_id, "status": status, "error": error,
|
||||||
@@ -904,6 +908,239 @@ def test_worker_stops_when_nothing_due():
|
|||||||
assert worker.process_once(st, _FakeApi(_WorkerClient())) == 0
|
assert worker.process_once(st, _FakeApi(_WorkerClient())) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 카페24 읽기 지연 보정 — 마지막 쓰기가 권위
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def test_resolve_description_no_recent_write_uses_cafe24():
|
||||||
|
html, state = store.resolve_description("cafe", last_write=None, known_digests=set(), grace_minutes=60)
|
||||||
|
assert (html, state) == ("cafe", store.SYNC_NONE)
|
||||||
|
|
||||||
|
|
||||||
|
def _write(html, minutes_ago=1):
|
||||||
|
return {"html_content": html, "created_at": now_kst() - timedelta(minutes=minutes_ago)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_description_synced_when_cafe24_caught_up():
|
||||||
|
html, state = store.resolve_description(
|
||||||
|
"NEW", last_write=_write("NEW"), known_digests={store.content_digest("OLD")}, grace_minutes=60
|
||||||
|
)
|
||||||
|
assert (html, state) == ("NEW", store.SYNC_SYNCED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_description_pending_when_cafe24_returns_known_old_value():
|
||||||
|
# 카페24가 아직 적용 직전 값(BACKUP 으로 남긴 값)을 돌려준다 → 마지막 쓰기를 보여준다
|
||||||
|
digests = {store.content_digest("OLD"), store.content_digest("NEW")}
|
||||||
|
html, state = store.resolve_description("OLD", last_write=_write("NEW"), known_digests=digests, grace_minutes=60)
|
||||||
|
assert (html, state) == ("NEW", store.SYNC_PENDING)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_description_pending_for_older_known_value_too():
|
||||||
|
# 연속 두 번 적용(A→B→C) 뒤 카페24가 A 를 돌려줘도 '아는 값'이므로 지연으로 본다
|
||||||
|
digests = {store.content_digest(v) for v in ("A", "B", "C")}
|
||||||
|
html, state = store.resolve_description("A", last_write=_write("C"), known_digests=digests, grace_minutes=60)
|
||||||
|
assert (html, state) == ("C", store.SYNC_PENDING)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_description_external_when_unknown_value():
|
||||||
|
# 관리자에서 직접 고친 값 — 카페24를 믿는다
|
||||||
|
digests = {store.content_digest("OLD"), store.content_digest("NEW")}
|
||||||
|
html, state = store.resolve_description("HAND", last_write=_write("NEW"), known_digests=digests, grace_minutes=60)
|
||||||
|
assert (html, state) == ("HAND", store.SYNC_EXTERNAL)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_description_grace_expired_trusts_cafe24():
|
||||||
|
digests = {store.content_digest("OLD"), store.content_digest("NEW")}
|
||||||
|
html, state = store.resolve_description(
|
||||||
|
"OLD", last_write=_write("NEW", minutes_ago=500), known_digests=digests, grace_minutes=60
|
||||||
|
)
|
||||||
|
assert (html, state) == ("OLD", store.SYNC_NONE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_description_accepts_iso_created_at():
|
||||||
|
lw = {"html_content": "NEW", "created_at": (now_kst() - timedelta(minutes=2)).isoformat()}
|
||||||
|
html, state = store.resolve_description("OLD", last_write=lw, known_digests={store.content_digest("OLD")}, grace_minutes=60)
|
||||||
|
assert (html, state) == ("NEW", store.SYNC_PENDING)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_grace_minutes():
|
||||||
|
assert store.parse_grace_minutes("30") == 30
|
||||||
|
assert store.parse_grace_minutes("") == store.DEFAULT_READ_LAG_GRACE_MIN
|
||||||
|
assert store.parse_grace_minutes("-5") == store.DEFAULT_READ_LAG_GRACE_MIN
|
||||||
|
assert store.parse_grace_minutes("abc") == store.DEFAULT_READ_LAG_GRACE_MIN
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_recent_write_uses_snapshot_when_get_is_older():
|
||||||
|
fetched = {"product_name": "OLD", "price": "1000.00", "updated_date": "2026-09-18T10:00:00+09:00",
|
||||||
|
"description": "keep"}
|
||||||
|
snap = {"product_name": "NEW", "price": "2000.00", "updated_date": "2026-09-18T10:05:00+09:00"}
|
||||||
|
merged, stale = store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60)
|
||||||
|
assert stale is True
|
||||||
|
assert merged["product_name"] == "NEW" and merged["price"] == "2000.00"
|
||||||
|
assert merged["description"] == "keep" # 스냅샷에 없는 필드는 그대로
|
||||||
|
assert merged["updated_date"] == snap["updated_date"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_recent_write_keeps_cafe24_when_caught_up():
|
||||||
|
fetched = {"product_name": "NEWER", "updated_date": "2026-09-18T10:06:00+09:00"}
|
||||||
|
snap = {"product_name": "NEW", "updated_date": "2026-09-18T10:05:00+09:00"}
|
||||||
|
merged, stale = store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60)
|
||||||
|
assert stale is False and merged is fetched
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_recent_write_skips_without_dates_or_snapshot():
|
||||||
|
fetched = {"product_name": "X"}
|
||||||
|
assert store.overlay_recent_write(fetched, snapshot=None, written_at=now_kst(), grace_minutes=60) == (fetched, False)
|
||||||
|
snap = {"product_name": "NEW"}
|
||||||
|
assert store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60) == (fetched, False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_recent_write_respects_grace():
|
||||||
|
fetched = {"product_name": "OLD", "updated_date": "2026-09-18T10:00:00+09:00"}
|
||||||
|
snap = {"product_name": "NEW", "updated_date": "2026-09-18T10:05:00+09:00"}
|
||||||
|
old = (now_kst() - timedelta(hours=10)).isoformat()
|
||||||
|
assert store.overlay_recent_write(fetched, snapshot=snap, written_at=old, grace_minutes=60) == (fetched, False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_product_snapshot_keeps_only_scalar_fields():
|
||||||
|
snap = store.product_snapshot({"product_name": "A", "price": "1.00", "description": "<big>", "updated_date": None, "display": "T"})
|
||||||
|
assert snap == {"product_name": "A", "price": "1.00", "display": "T"}
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 기본 정보 · 옵션 · 품목 입력 검증
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def test_parse_price():
|
||||||
|
assert store.parse_price("6,900") == "6900.00"
|
||||||
|
assert store.parse_price("6900.00") == "6900.00"
|
||||||
|
assert store.parse_price(6900) == "6900.00"
|
||||||
|
assert store.parse_price("") is None
|
||||||
|
assert store.parse_price(None) is None
|
||||||
|
for bad in ("abc", "-1"):
|
||||||
|
try:
|
||||||
|
store.parse_price(bad)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError(bad)
|
||||||
|
assert store.price_equal("6900.00", "6900") and not store.price_equal("6900.00", "7000")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_payload_price_and_image():
|
||||||
|
payload = products.build_update_payload(price="1000.00", supply_price="500.00", detail_image="/web/x.jpg")
|
||||||
|
assert payload["request"] == {
|
||||||
|
"price": "1000.00", "supply_price": "500.00", "detail_image": "/web/x.jpg", "image_upload_type": "A",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_option_values():
|
||||||
|
assert store.parse_option_values("빨강, 파랑\n노랑,, 빨강 ") == ["빨강", "파랑", "노랑"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_create_options_request():
|
||||||
|
body = store.build_create_options_request("색상", ["빨강", "파랑"], display_type="p")
|
||||||
|
assert body["has_option"] == "T" and body["option_type"] == "T"
|
||||||
|
assert body["options"][0]["option_display_type"] == "P"
|
||||||
|
assert [v["option_text"] for v in body["options"][0]["option_value"]] == ["빨강", "파랑"]
|
||||||
|
for name, values in (("", ["a"]), ("x", [])):
|
||||||
|
try:
|
||||||
|
store.build_create_options_request(name, values)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError((name, values))
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_update_options_request_pairs_original_and_new():
|
||||||
|
original = [{"option_code": "O1", "option_name": "Color", "option_display_type": "S",
|
||||||
|
"option_value": [{"option_text": "Black", "value_no": 1}, {"option_text": "Red", "value_no": 2}]}]
|
||||||
|
edited = [{"option_name": "Colors", "option_display_type": "P",
|
||||||
|
"option_value": [{"option_text": "Jet Black", "option_image_file": "https://d/x.png"},
|
||||||
|
{"option_text": "Deep Red"}]}]
|
||||||
|
body = store.build_update_options_request(original, edited, option_list_type="S")
|
||||||
|
assert body["option_list_type"] == "S"
|
||||||
|
assert body["original_options"] == [{"option_code": "O1", "option_name": "Color",
|
||||||
|
"option_value": [{"option_text": "Black", "value_no": 1}, {"option_text": "Red", "value_no": 2}]}]
|
||||||
|
assert body["options"][0]["option_name"] == "Colors"
|
||||||
|
assert body["options"][0]["option_display_type"] == "P"
|
||||||
|
assert body["options"][0]["option_value"][0] == {"option_text": "Jet Black", "value_no": 1, "option_image_file": "https://d/x.png"}
|
||||||
|
assert body["options"][0]["option_value"][1] == {"option_text": "Deep Red", "value_no": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_update_options_request_rejects_count_mismatch():
|
||||||
|
original = [{"option_name": "A", "option_value": [{"option_text": "1"}]}]
|
||||||
|
for edited in ([], [{"option_name": "A", "option_value": []}], [{"option_name": "", "option_value": [{"option_text": "1"}]}]):
|
||||||
|
try:
|
||||||
|
store.build_update_options_request(original, edited)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError(edited)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_variant_updates():
|
||||||
|
rows = [
|
||||||
|
{"variant_code": "p000000r000a", "custom_variant_code": " ABC ", "additional_amount": "1,000", "display": "on"},
|
||||||
|
{"variant_code": "P000000R000B"}, # 바뀐 것 없음 → 제외
|
||||||
|
{"variant_code": "P000000R000C", "selling": "off", "additional_amount": ""},
|
||||||
|
]
|
||||||
|
out = store.build_variant_updates(rows)
|
||||||
|
assert out == [
|
||||||
|
{"variant_code": "P000000R000A", "custom_variant_code": "ABC", "additional_amount": "1000.00", "display": "T"},
|
||||||
|
{"variant_code": "P000000R000C", "selling": "F"},
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
store.build_variant_updates([{"variant_code": "bad"}])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("bad code accepted")
|
||||||
|
|
||||||
|
|
||||||
|
class _RouteClient:
|
||||||
|
"""경로별 응답을 돌려주는 가짜 클라이언트 (get/put/post/delete)."""
|
||||||
|
|
||||||
|
def __init__(self, routes):
|
||||||
|
self.routes = routes
|
||||||
|
self.calls: list[tuple] = []
|
||||||
|
|
||||||
|
def _call(self, method, path, json=None, **_kw):
|
||||||
|
self.calls.append((method, path, json))
|
||||||
|
return self.routes.get(f"{method} {path}", {})
|
||||||
|
|
||||||
|
def get(self, path, **kw):
|
||||||
|
return self._call("GET", path, **kw)
|
||||||
|
|
||||||
|
def put(self, path, **kw):
|
||||||
|
return self._call("PUT", path, **kw)
|
||||||
|
|
||||||
|
def post(self, path, **kw):
|
||||||
|
return self._call("POST", path, **kw)
|
||||||
|
|
||||||
|
def delete(self, path, **kw):
|
||||||
|
return self._call("DELETE", path, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_images_and_variants_wrappers():
|
||||||
|
client = _RouteClient({
|
||||||
|
"POST /admin/products/images": {"images": [{"path": "https://d/a.png"}, {"path": "https://d/b.png"}]},
|
||||||
|
"PUT /admin/products/7/variants": {"variants": [{"variant_code": "P000000R000A", "display": "F"}]},
|
||||||
|
"GET /admin/products/7/variants": {"variants": [{"variant_code": "P000000R000A"}]},
|
||||||
|
"GET /admin/products/7/options": {"option": {"has_option": "T", "options": []}},
|
||||||
|
"DELETE /admin/products/7/options": {"option": {"product_no": 7}},
|
||||||
|
})
|
||||||
|
assert products.upload_images(client, ["AAA", "BBB"]) == ["https://d/a.png", "https://d/b.png"]
|
||||||
|
assert products.upload_image_bytes(client, b"\x89PNG") == "https://d/a.png"
|
||||||
|
assert products.list_variants(client, 7) == [{"variant_code": "P000000R000A"}]
|
||||||
|
assert products.get_options(client, 7)["has_option"] == "T"
|
||||||
|
assert products.update_variants(client, 7, [{"variant_code": "P000000R000A", "display": "F"}]) == [
|
||||||
|
{"variant_code": "P000000R000A", "display": "F"}
|
||||||
|
]
|
||||||
|
sent = [c for c in client.calls if c[0] == "PUT"][-1]
|
||||||
|
assert sent[2]["requests"][0]["variant_code"] == "P000000R000A"
|
||||||
|
products.delete_options(client, 7)
|
||||||
|
assert client.calls[-1][0] == "DELETE"
|
||||||
|
|
||||||
|
|
||||||
def _run_all():
|
def _run_all():
|
||||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||||
for fn in fns:
|
for fn in fns:
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def _apply(store_db: Any, api: Any, row: dict[str, Any]) -> str:
|
|||||||
# PC/모바일은 구분하지 않는다 — mobile_description 은 보내지 않고
|
# PC/모바일은 구분하지 않는다 — mobile_description 은 보내지 않고
|
||||||
# separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제한다.
|
# separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제한다.
|
||||||
# (mobile_description 을 직접 보내면 카페24가 그 설정을 "직접 등록"으로 바꿔버린다.)
|
# (mobile_description 을 직접 보내면 카페24가 그 설정을 "직접 등록"으로 바꿔버린다.)
|
||||||
products.update_product(
|
updated = products.update_product(
|
||||||
api.client,
|
api.client,
|
||||||
product_no,
|
product_no,
|
||||||
description=html,
|
description=html,
|
||||||
@@ -88,10 +88,26 @@ def _apply(store_db: Any, api: Any, row: dict[str, Any]) -> str:
|
|||||||
display=set_display,
|
display=set_display,
|
||||||
selling=set_selling,
|
selling=set_selling,
|
||||||
)
|
)
|
||||||
|
# PUT 응답은 쓰기 직후의 실제 값이다 — 스냅샷으로 남겨 화면이 GET 의 읽기 지연에
|
||||||
|
# 흔들리지 않게 한다(routes_products._load_product 가 덮어씌운다).
|
||||||
|
if isinstance(updated, dict) and updated.get("product_no"):
|
||||||
|
try:
|
||||||
|
store_db.save_write_snapshot(product_no, "product", store.product_snapshot(updated))
|
||||||
|
except Exception: # noqa: BLE001 — 스냅샷 실패가 예약 결과를 바꾸면 안 된다.
|
||||||
|
logger.exception("예약 #%s 상품 %s 스냅샷 저장 실패", schedule_id, product_no)
|
||||||
if html is not None:
|
if html is not None:
|
||||||
# 화면 편집(apply)과 동일 — 카페24 관리자 API 의 쓰기 직후 읽기 지연을
|
# 화면 편집(apply)과 동일 — 카페24 관리자 API 의 쓰기 직후 읽기 지연을
|
||||||
# 여기서 짧게 흡수한다(쇼핑몰에는 바로 반영되지만 관리자 조회만 뒤쳐질 때가 있다).
|
# 여기서 짧게 흡수한다(쇼핑몰에는 바로 반영되지만 관리자 조회만 뒤쳐질 때가 있다).
|
||||||
|
# 확인이 안 돼도 실패가 아니다 — 화면은 마지막 쓰기(SCHEDULED revision)를 기준으로
|
||||||
|
# 그린다. 여기서 SCHEDULED revision 을 남겨야 그 기준이 생긴다.
|
||||||
products.wait_for_description(api.client, product_no, html)
|
products.wait_for_description(api.client, product_no, html)
|
||||||
|
store_db.add_revision(
|
||||||
|
product_no=product_no,
|
||||||
|
html_content=html,
|
||||||
|
revision_type=store.REVISION_SCHEDULED,
|
||||||
|
memo=f"예약 #{schedule_id} 적용",
|
||||||
|
created_by=ACTOR,
|
||||||
|
)
|
||||||
|
|
||||||
summary = store.describe_schedule_action(
|
summary = store.describe_schedule_action(
|
||||||
has_html=html is not None, set_display=set_display, set_selling=set_selling
|
has_html=html is not None, set_display=set_display, set_selling=set_selling
|
||||||
|
|||||||
+396
-4
@@ -11,7 +11,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다.
|
/* 분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다.
|
||||||
box-sizing 을 함께 바꿔야 한다: .erp-page 는 width:100% + padding:24px 라서
|
box-sizing 을 함께 바꿔야 한다: .erp-page 는 width:100% + padding:24px 라서
|
||||||
max-width 를 풀면 padding 이 폭에 더해져 문서 전체에 가로 스크롤이 생긴다. */
|
max-width 를 풀면 padding 이 폭에 더해져 문서 전체에 가로 스크롤이 생긴다. */
|
||||||
.erp-page {
|
.erp-page {
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════════════
|
||||||
좌우 2분할: 왼쪽 목록(좁게) | 오른쪽 상세페이지 편집(넓게)
|
좌우 분할: 왼쪽 목록(좁게) | 가운데 상세페이지 편집(넓게) | 오른쪽 정보 패널
|
||||||
각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다.
|
(.cf24-split-3 — 아래 3분할 절). 각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다.
|
||||||
════════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════════ */
|
||||||
.cf24-split {
|
.cf24-split {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
.cf24-col-name { word-break: break-word; }
|
.cf24-col-name { word-break: break-word; }
|
||||||
|
|
||||||
/* 긴 상품명은 2줄까지만 — 행 높이를 고르게 유지해 목록을 훑기 쉽게 한다.
|
/* 긴 상품명은 2줄까지만 — 행 높이를 고르게 유지해 목록을 훑기 쉽게 한다.
|
||||||
전체 이름은 title 툴팁과 오른쪽 편집기 제목에서 확인한다. */
|
전체 이름은 title 툴팁과 편집기 제목에서 확인한다. */
|
||||||
.cf24-col-name a {
|
.cf24-col-name a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -803,3 +803,395 @@
|
|||||||
font-family: var(--font-geist-mono, ui-monospace, monospace);
|
font-family: var(--font-geist-mono, ui-monospace, monospace);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════
|
||||||
|
3분할 — 목록 | 편집기 | 상품 정보 패널
|
||||||
|
왼쪽 목록은 좁게, 가운데 편집기가 남는 폭을, 오른쪽 패널은 고정폭.
|
||||||
|
════════════════════════════════════════════════════════════ */
|
||||||
|
.cf24-split.cf24-split-3 {
|
||||||
|
/* 목록은 320~460px 사이에서 줄어들고, 편집기가 남는 폭을, 패널은 380px 고정.
|
||||||
|
1920px: 460 + 380 → 편집기 약 1000px. 1400px: 320 + 380 → 편집기 약 640px. */
|
||||||
|
grid-template-columns: minmax(320px, 460px) minmax(0, 1fr) 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1360px) {
|
||||||
|
/* 폭이 모자라면 정보 패널을 편집기 아래로 내린다(목록은 그대로 왼쪽).
|
||||||
|
아래 칸은 높이를 제한해 편집기가 너무 눌리지 않게 한다(패널 안에서 스크롤). */
|
||||||
|
.cf24-split.cf24-split-3 {
|
||||||
|
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
|
||||||
|
grid-template-areas: "list editor" "list side";
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
.cf24-split-3 > .cf24-pane-list { grid-area: list; }
|
||||||
|
.cf24-split-3 > .cf24-pane-editor { grid-area: editor; }
|
||||||
|
.cf24-split-3 > .cf24-pane-side { grid-area: side; max-height: 38vh; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.cf24-split.cf24-split-3 {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-areas: none;
|
||||||
|
}
|
||||||
|
.cf24-split-3 > .cf24-pane-list,
|
||||||
|
.cf24-split-3 > .cf24-pane-editor,
|
||||||
|
.cf24-split-3 > .cf24-pane-side { grid-area: auto; max-height: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-pane-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-12, 12px);
|
||||||
|
padding: var(--sp-12, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 읽기 지연 안내 배너 */
|
||||||
|
.cf24-flash-warn {
|
||||||
|
background: #fff8e6;
|
||||||
|
border: 1px solid #f0b429;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-btn-inline {
|
||||||
|
margin-left: var(--sp-8, 8px);
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 정보 패널 공통 ── */
|
||||||
|
.cf24-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-12, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-side-sec {
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-lg, 10px);
|
||||||
|
padding: var(--sp-10, 10px) var(--sp-12, 12px);
|
||||||
|
background: var(--color-canvas-white, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-side-title {
|
||||||
|
margin: 0 0 var(--sp-8, 8px);
|
||||||
|
font-size: var(--text-body, 14px);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-side-details > summary.cf24-side-title {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-side-details[open] > summary.cf24-side-title {
|
||||||
|
margin-bottom: var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-side-note {
|
||||||
|
margin: 0 0 var(--sp-8, 8px);
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-side-note-warn {
|
||||||
|
color: #9a6700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-side-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-side-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-side-field input[type="text"],
|
||||||
|
.cf24-side-field select {
|
||||||
|
height: 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 var(--sp-8, 8px);
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-md, 6px);
|
||||||
|
font-size: var(--text-body, 14px);
|
||||||
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
|
background: var(--color-canvas-white, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-side-field input:focus,
|
||||||
|
.cf24-side-field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-rich-black, #0a0a0a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 값이 바뀐 입력칸은 테두리로 표시 — 무엇이 저장될지 보이게 */
|
||||||
|
.cf24-side input.is-dirty,
|
||||||
|
.cf24-side select.is-dirty {
|
||||||
|
border-color: #f0b429;
|
||||||
|
background: #fffbea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-side-grid3 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-side-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-side-actions .cf24-muted {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-side-actions .is-ok { color: var(--color-success-green, #10c22b); }
|
||||||
|
.cf24-side-actions .is-err { color: var(--color-callout-red, #c22b10); }
|
||||||
|
|
||||||
|
/* ── 대표 이미지 ── */
|
||||||
|
.cf24-image-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 160px;
|
||||||
|
border: 1px dashed var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-md, 6px);
|
||||||
|
background: var(--color-ghost-gray, #f6f8fa);
|
||||||
|
margin-bottom: var(--sp-8, 8px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-image-main {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 260px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-image-empty {
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
padding: var(--sp-16, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-image-thumbs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
margin-bottom: var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-image-thumb {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-image-thumb img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-sm, 4px);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-image-thumb img.is-hidden,
|
||||||
|
.cf24-side .is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-file-pick {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-file-pick input[type="file"] {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-file-name {
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 옵션 / 품목 ── */
|
||||||
|
.cf24-options-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-10, 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-opt-group {
|
||||||
|
border-top: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
padding-top: var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-opt-head {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-opt-head .cf24-side-field { flex: 1 1 auto; }
|
||||||
|
.cf24-opt-head .cf24-side-field-type { flex: 0 0 140px; }
|
||||||
|
|
||||||
|
.cf24-opt-value {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-opt-thumb {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-sm, 4px);
|
||||||
|
background: var(--color-ghost-gray, #f6f8fa);
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-opt-thumb-empty {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px dashed var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-sm, 4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-opt-value input[type="text"] {
|
||||||
|
height: 30px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 var(--sp-8, 8px);
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-md, 6px);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-opt-value .cf24-file-pick .erp-btn {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 품목 표 — 좁은 칸에 맞춘 작은 글자 */
|
||||||
|
.cf24-variants {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-variants th,
|
||||||
|
.cf24-variants td {
|
||||||
|
padding: 4px 4px;
|
||||||
|
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
vertical-align: middle;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-variants th {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-variants td.cf24-v-name {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-variants input[type="text"] {
|
||||||
|
height: 28px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-sm, 4px);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-variants .cf24-v-amount input { text-align: right; }
|
||||||
|
/* "미진열"(3글자)이 한 줄에 들어가는 폭. 실측: 44px 에서는 두 줄로 꺾였다. */
|
||||||
|
.cf24-variants .cf24-v-flag { text-align: center; width: 54px; }
|
||||||
|
.cf24-variants .cf24-v-code { width: 92px; }
|
||||||
|
.cf24-variants .cf24-v-amount { width: 76px; }
|
||||||
|
|
||||||
|
/* 진열/판매 토글 — 저장 전까지는 화면만 바뀐다(배지와 같은 색 규칙) */
|
||||||
|
.cf24-v-toggle {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--color-ghost-gray, #f2f2f2);
|
||||||
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-v-toggle.is-on {
|
||||||
|
background: var(--color-success-green, #10c22b);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-v-toggle.is-dirty {
|
||||||
|
box-shadow: 0 0 0 2px #f0b429;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-variants-wrap {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-opt-danger {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-opt-danger .erp-btn {
|
||||||
|
color: var(--color-callout-red, #c22b10);
|
||||||
|
border-color: var(--color-callout-red, #c22b10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-opt-create textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: var(--sp-8, 8px);
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-md, 6px);
|
||||||
|
font-size: 13px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|||||||
+129
-27
@@ -26,7 +26,9 @@ app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리
|
|||||||
|
|
||||||
app/modules/cafe24/ ← 상품관리 모듈
|
app/modules/cafe24/ ← 상품관리 모듈
|
||||||
├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합
|
├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합
|
||||||
├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기)
|
├─ routes_products.py 3분할 화면 · 편집기+정보패널 조각 · 적용(쓰기) · 읽기 지연 보정
|
||||||
|
├─ routes_product_info.py 오른쪽 정보 패널 JSON API — 상품명/가격 · 대표이미지 ·
|
||||||
|
│ 옵션(생성/수정/삭제) · 품목(자체코드/추가금액/진열/판매)
|
||||||
├─ routes_schedules.py 예약 등록·목록·취소
|
├─ routes_schedules.py 예약 등록·목록·취소
|
||||||
├─ routes_design_files.py 디자인 보관함 파일 편집·적용(FTP) — 모바일 스와이프·
|
├─ routes_design_files.py 디자인 보관함 파일 편집·적용(FTP) — 모바일 스와이프·
|
||||||
│ PC/모바일 상품상세 템플릿(file_key 로 하나의 라우트 공유)
|
│ PC/모바일 상품상세 템플릿(file_key 로 하나의 라우트 공유)
|
||||||
@@ -36,8 +38,9 @@ app/modules/cafe24/ ← 상품관리 모듈
|
|||||||
├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL)
|
├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL)
|
||||||
├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증
|
├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증
|
||||||
├─ tests/ DB/네트워크 없는 유닛테스트
|
├─ tests/ DB/네트워크 없는 유닛테스트
|
||||||
└─ templates/cafe24/ _nav.html · products.html(2분할) ·
|
└─ templates/cafe24/ _nav.html · products.html(3분할 + 모든 JS) ·
|
||||||
_editor.html(오른쪽 조각) · design_editor.html ·
|
_editor.html(가운데 조각) · _side.html(오른쪽 정보 패널 조각) ·
|
||||||
|
_panes.html(두 조각 묶음 — /pane 응답) · design_editor.html ·
|
||||||
schedules.html · system.html
|
schedules.html · system.html
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -54,12 +57,20 @@ app/modules/cafe24/ ← 상품관리 모듈
|
|||||||
|
|
||||||
| 경로 | 화면 | 권한 |
|
| 경로 | 화면 | 권한 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET /cafe24/` | 2분할 화면 (`q`, `display`, `selling`, `selected`) | `cafe24` |
|
| `GET /cafe24/` | 3분할 화면 (`q`, `display`, `selling`, `selected`) | `cafe24` |
|
||||||
| `GET /cafe24/products/{product_no}/pane` | 오른쪽 편집기 조각 (JS 가 가져감) | `cafe24` |
|
| `GET /cafe24/products/{product_no}/pane` | 편집기 + 정보 패널 조각 묶음 (JS 가 가져가 `[data-pane]` 별로 끼움) | `cafe24` |
|
||||||
| `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` |
|
| `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` |
|
||||||
| `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` |
|
| `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` |
|
||||||
| `POST /cafe24/products/{product_no}/status` | 진열/판매 토글 (JSON: `{field, value}` → 적용 후 상태) | `cafe24` |
|
| `POST /cafe24/products/{product_no}/status` | 진열/판매 토글 (JSON: `{field, value}` → 적용 후 상태) | `cafe24` |
|
||||||
| `POST /cafe24/products/{product_no}/name` | 상품명 변경 (JSON: `{name}` → 적용된 이름). 이전 이름은 감사로그 `rename_product` | `cafe24` |
|
| `POST /cafe24/products/{product_no}/name` | 상품명 변경 (JSON: `{name}` → 적용된 이름). 이전 이름은 감사로그 `rename_product` | `cafe24` |
|
||||||
|
| `POST /cafe24/products/{product_no}/basic` | 상품명·판매가·공급가·소비자가 (JSON, 바뀐 것만 PUT). 감사로그 `update_basic` | `cafe24` |
|
||||||
|
| `POST /cafe24/products/{product_no}/image` | 대표 이미지 교체 (multipart `file`) — 업로드 후 PUT `detail_image` + `image_upload_type=A`. 감사로그 `set_main_image` | `cafe24` |
|
||||||
|
| `GET /cafe24/products/{product_no}/options` | 옵션 + 품목 조회 (JSON, 읽기 지연 보정 포함) | `cafe24` |
|
||||||
|
| `POST /cafe24/products/{product_no}/options` | 옵션 생성 (`{option_name, values, display_type}`) — 조합형, 품목 자동 생성 | `cafe24` |
|
||||||
|
| `PUT /cafe24/products/{product_no}/options` | 옵션명·옵션값 이름/썸네일/표시방식 수정 (`{options:[...], option_list_type}`) | `cafe24` |
|
||||||
|
| `DELETE /cafe24/products/{product_no}/options` | 옵션 삭제 — **품목도 함께 삭제**(화면 확인창) | `cafe24` |
|
||||||
|
| `POST /cafe24/products/{product_no}/options/image` | 옵션값 썸네일 업로드만 (multipart → `{path}`) | `cafe24` |
|
||||||
|
| `PUT /cafe24/products/{product_no}/variants` | 품목 자체코드·추가금액·진열·판매 일괄 수정 (`{rows:[...]}`, 바뀐 행만) | `cafe24` |
|
||||||
| `GET /cafe24/schedules` | 예약 목록 · 취소 | `cafe24` |
|
| `GET /cafe24/schedules` | 예약 목록 · 취소 | `cafe24` |
|
||||||
| `POST /cafe24/schedules` | 예약 등록 (편집기에서) | `cafe24` |
|
| `POST /cafe24/schedules` | 예약 등록 (편집기에서) | `cafe24` |
|
||||||
| `POST /cafe24/schedules/{id}/cancel` | 대기 중 예약 취소 | `cafe24` |
|
| `POST /cafe24/schedules/{id}/cancel` | 대기 중 예약 취소 | `cafe24` |
|
||||||
@@ -120,19 +131,29 @@ Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 방법이 없다(`config.p
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2-1. 상품관리 화면 구성 (2분할)
|
### 2-1. 상품관리 화면 구성 (3분할)
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─ 550px ───────────────────┬───────── 남은 폭 전부 ─────────┐
|
┌─ 320~460px ──────────┬──────── 남은 폭 전부 ────────┬─ 380px ─────────────┐
|
||||||
│ 상품명 검색(한 줄) │ 상품 이름 · 번호 · 진열/판매 │
|
│ 상품명 검색(한 줄) │ [반영 대기 배너 — 지연 시] │ 기본 정보 │
|
||||||
│ ☑진열중 ☑판매중 (기본 체크) │ 상세설명 HTML 편집기 │
|
│ ☑진열중 ☑판매중 │ 상품 이름 · 번호 · 진열/판매 │ 상품명/판매가/공급가 │
|
||||||
│ ── 목록(전체, 스크롤) ── │ (PC/모바일 공통 · 문법 강조) │
|
│ ── 목록(전체) ── │ 상세설명 HTML 편집기 │ /소비자가 [저장] │
|
||||||
│ 번호 상품명 진열 판매 수정 │ [메모][복사][다시 읽기][적용] │
|
│ 번호 상품명 진열 판매 │ (PC/모바일 공통 · 문법 강조) │ 대표 이미지 │
|
||||||
│ (제목행 클릭 = 정렬) │ ▸ 버전 이력 │
|
│ (제목행 클릭 = 정렬) │ [메모][복사][다시 읽기][적용] │ 큰 그림 + 목록/축소 │
|
||||||
│ │ │
|
│ │ ▸ 예약 적용 │ [파일 선택][교체] │
|
||||||
└───────────────────────────┴─────────────────────────────────┘
|
│ │ ▸ 버전 이력 │ ▸ 옵션 / 품목 (지연 로드) │
|
||||||
|
└───────────────────────┴───────────────────────────────┴──────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- 오른쪽 **상품 정보 패널**(`_side.html`)은 "소스 수정 외 기능"을 모아둔 별도 공간이다.
|
||||||
|
상품 클릭 시 `/products/{no}/pane` 이 편집기 조각과 패널 조각을 **한 응답**(`_panes.html`,
|
||||||
|
`[data-pane=editor]`/`[data-pane=side]`)으로 돌려주고 JS 가 각각 끼운다 — 카페24 상품
|
||||||
|
조회는 1회. 옵션/품목은 `<details>` 를 펼칠 때 JSON 으로 따로 불러온다(호출 2회 절약).
|
||||||
|
- 1360px 이하에서는 패널이 편집기 아래로 내려가고(높이 38vh, 안에서 스크롤), 1100px 이하는
|
||||||
|
한 열이다.
|
||||||
|
- 패널의 모든 쓰기는 JSON API(`routes_product_info.py`)이며 화면은 **응답값**으로 다시
|
||||||
|
그린다. 저장 뒤 카페24를 다시 GET 하지 않는다(아래 3-3 읽기 지연 참고).
|
||||||
|
|
||||||
- 검색 입력란은 **한 줄 높이로 고정**한다(`height: 32px`). `.cf24-filters` 가 세로
|
- 검색 입력란은 **한 줄 높이로 고정**한다(`height: 32px`). `.cf24-filters` 가 세로
|
||||||
flex 이므로 `flex-basis` 를 주면 그 값이 **높이**로 적용돼 입력란이 거대해진다.
|
flex 이므로 `flex-basis` 를 주면 그 값이 **높이**로 적용돼 입력란이 거대해진다.
|
||||||
실제로 그 사고가 있었다 — flex 방향을 항상 확인할 것.
|
실제로 그 사고가 있었다 — flex 방향을 항상 확인할 것.
|
||||||
@@ -148,8 +169,8 @@ Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 방법이 없다(`config.p
|
|||||||
이 표식은 `_list_query` 가 링크·리다이렉트에도 이어 붙여 해제 상태가 유지된다.
|
이 표식은 `_list_query` 가 링크·리다이렉트에도 이어 붙여 해제 상태가 유지된다.
|
||||||
- **정렬**은 제목행 클릭(오름↔내림 토글). 브라우저에서 처리하므로 전체를 받아둔
|
- **정렬**은 제목행 클릭(오름↔내림 토글). 브라우저에서 처리하므로 전체를 받아둔
|
||||||
덕분에 목록 전체가 대상이 된다.
|
덕분에 목록 전체가 대상이 된다.
|
||||||
- **상품 클릭 시 오른쪽만 교체**한다(`/pane` 조각을 fetch → 삽입). 목록을 다시 받지
|
- **상품 클릭 시 편집기·정보 패널만 교체**한다(`/pane` 조각 묶음을 fetch → 삽입). 목록을
|
||||||
않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다.
|
다시 받지 않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다.
|
||||||
- 편집기 상단에 **상품 다이렉트 주소**(고객이 보는 상세페이지 URL)와 「주소 복사」·
|
- 편집기 상단에 **상품 다이렉트 주소**(고객이 보는 상세페이지 URL)와 「주소 복사」·
|
||||||
「쇼핑몰에서 열기」를 둔다. 주소는 `CAFE24_SHOP_URL` 기준으로 만들고, 미설정 시
|
「쇼핑몰에서 열기」를 둔다. 주소는 `CAFE24_SHOP_URL` 기준으로 만들고, 미설정 시
|
||||||
카페24 기본 도메인(`https://<mall_id>.cafe24.com`)으로 대체한다 — 커스텀 도메인은
|
카페24 기본 도메인(`https://<mall_id>.cafe24.com`)으로 대체한다 — 커스텀 도메인은
|
||||||
@@ -341,12 +362,16 @@ cafe24_oauth_tokens 저장
|
|||||||
정리된 소스가 그대로 저장된다).
|
정리된 소스가 그대로 저장된다).
|
||||||
1. **카페24에서 현재 HTML 을 다시 읽는다.** 로컬 DB 의 마지막 버전을 "지금
|
1. **카페24에서 현재 HTML 을 다시 읽는다.** 로컬 DB 의 마지막 버전을 "지금
|
||||||
올라간 값"으로 가정하지 않는다(카페24 관리자에서 직접 고쳤을 수 있다).
|
올라간 값"으로 가정하지 않는다(카페24 관리자에서 직접 고쳤을 수 있다).
|
||||||
2. 그 값으로 **BACKUP revision** 을 남긴다. 유일한 복구 수단이다.
|
1-1. **읽기 지연 판정으로 유효 현재값을 정한다**(3-3). 카페24가 아직 예전 값을
|
||||||
|
돌려주는 중이면 우리 마지막 쓰기(MANUAL/SCHEDULED)가 현재값이다.
|
||||||
|
2. 유효 현재값으로 **BACKUP revision** 을 남긴다. 유일한 복구 수단이다.
|
||||||
3. **지문 대조** — 편집 화면을 열 때의 `fingerprint`(sha256 앞 32자)와 지금
|
3. **지문 대조** — 편집 화면을 열 때의 `fingerprint`(sha256 앞 32자)와 지금
|
||||||
카페24 값의 지문이 다르면 적용을 거부한다. 편집 중 남이 바꾼 내용을 조용히
|
유효 현재값의 지문이 다르면 적용을 거부한다. 편집 중 남이 바꾼 내용을 조용히
|
||||||
덮어쓰는 것을 막는 낙관적 잠금이다.
|
덮어쓰는 것을 막는 낙관적 잠금이다. (카페24의 지연된 예전 값과 비교하면
|
||||||
4. 내용이 같으면 호출하지 않는다(불필요한 쓰기·API 호출 방지).
|
방금 적용한 것이 "충돌"로 오판된다 — 그래서 유효 현재값과 비교한다.)
|
||||||
5. PUT 적용 → **MANUAL revision** + 감사로그(`apply_description`).
|
4. 내용이 유효 현재값과 같으면 호출하지 않는다(불필요한 쓰기·API 호출 방지).
|
||||||
|
5. PUT 적용 → PUT 응답을 **스냅샷**으로 저장 → **MANUAL revision** → 짧은 재조회
|
||||||
|
확인(결과는 감사로그에만) → 감사로그(`apply_description`).
|
||||||
|
|
||||||
추가 규칙:
|
추가 규칙:
|
||||||
|
|
||||||
@@ -361,12 +386,79 @@ cafe24_oauth_tokens 저장
|
|||||||
분리 사용 상품의 모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 **그 내용도 BACKUP
|
분리 사용 상품의 모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 **그 내용도 BACKUP
|
||||||
revision 으로 남긴다**(백업이 없으면 되찾을 방법이 없다).
|
revision 으로 남긴다**(백업이 없으면 되찾을 방법이 없다).
|
||||||
- 실패해도 BACKUP 은 이미 남아 있으므로 오류 메시지에 버전 번호를 알려준다.
|
- 실패해도 BACKUP 은 이미 남아 있으므로 오류 메시지에 버전 번호를 알려준다.
|
||||||
- **적용 직후 짧게 재확인한다(`products.wait_for_description`).** 카페24 관리자
|
- 적용 직후 `products.wait_for_description` 으로 0.8초 × 3회 재조회해 보지만, 이것은
|
||||||
API(`GET /admin/products/{no}`)는 PUT 직후 몇 초간 이전 값을 돌려줄 때가
|
**확인용**일 뿐 화면의 기준이 아니다(3-3). 확인 여부는 감사로그 detail 에 남는다.
|
||||||
있다(쇼핑몰 화면에는 바로 반영됨 — 실물 관찰). 그 상태에서 다른 상품을 봤다가
|
|
||||||
돌아오면 우리 편집기만 "적용 안 된 것"처럼 보인다. 적용/예약 실행 직후
|
---
|
||||||
0.8초 간격으로 최대 3회 재조회해 새 값이 확인될 때까지 기다린 뒤 화면으로
|
|
||||||
돌아간다(실패해도 PUT 자체는 이미 성공했으므로 예외를 던지지 않는다).
|
## 3-3. 카페24 읽기 지연(read-after-write lag) — "마지막 쓰기가 권위"
|
||||||
|
|
||||||
|
### 증상과 원인
|
||||||
|
|
||||||
|
"소스를 수정해 적용했는데 편집기에는 수정 전 소스가 보이고, 한참 뒤에 다시 들어가면
|
||||||
|
반영돼 있다." 우리 쪽 캐시가 아니다 — 화면·조각 응답은 전부 `Cache-Control: no-store`
|
||||||
|
이고 fetch 도 `no-store` 다. **카페24 관리자 API 가 PUT 직후 한동안 `GET
|
||||||
|
/admin/products/{no}` 에서 이전 값을 돌려준다**(실물 관찰. 몇 초일 때도, 훨씬 길 때도
|
||||||
|
있다. 쇼핑몰 고객 화면에는 바로 반영된다). 예전 코드는 2.4초만 기다리고 포기한 뒤 GET
|
||||||
|
값을 그대로 믿었기 때문에
|
||||||
|
|
||||||
|
1. 편집기에 예전 소스가 보이고,
|
||||||
|
2. 그 예전 값으로 지문을 만들어 다음 적용이 "충돌"로 거부되거나,
|
||||||
|
3. 예전 값이 BACKUP 으로 남고 "변경 없음" 판정이 어긋났다.
|
||||||
|
|
||||||
|
### 규칙
|
||||||
|
|
||||||
|
쓰기가 성공하면 **우리가 쓴 값을 DB 에 남기고, 유예시간 안에서는 그것을 현재값으로
|
||||||
|
삼는다.** 유예시간은 `CAFE24_READ_LAG_GRACE_MIN`(기본 360분). 지나면 무조건 카페24 값을
|
||||||
|
믿는다 — 지연은 영원하지 않고, 영원히 로컬 값을 고집하면 그것이 또 다른 캐시다.
|
||||||
|
|
||||||
|
| 대상 | 우리가 남기는 것 | 지연 판정 | 구현 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 상세설명 HTML | MANUAL/SCHEDULED revision (+ 적용 직전 BACKUP) | 카페24 값이 유예시간 안의 **revision 중 하나와 같으면** 지연(pending) → 마지막 쓰기를 표시하고 그 지문을 쓴다. 마지막 쓰기와 같으면 synced. 우리가 모르는 값이면 external(관리자에서 직접 고침) → 카페24 값 표시 | `store.resolve_description`, `routes_products.resolve_description`, DB `revision_digests`(md5) |
|
||||||
|
| 상품명·가격·이미지·진열/판매 | PUT 응답을 `cafe24_products.last_write_snapshot.product` 에 저장 | GET 의 `updated_date` 가 스냅샷의 `updated_date` 보다 **이전**이면 지연 → 스냅샷 값으로 덮어씀. 카페24 자신의 시각끼리 비교하므로 서버 시계와 무관 | `store.overlay_recent_write`, `routes_products.load_product` |
|
||||||
|
| 옵션 | PUT/POST 응답을 `…snapshot.options` 에 저장 | 유예시간 안이면 스냅샷 우선 | `routes_product_info._apply_options_snapshot` |
|
||||||
|
| 품목 | 보낸 값(+응답)을 `…snapshot.variants` 에 코드별 누적 | 유예시간 안이면 해당 코드의 필드만 덮어씀 | `routes_product_info._apply_variant_snapshot` |
|
||||||
|
|
||||||
|
- 편집기 위에 **「카페24 반영 대기 중」 배너**(pending) 또는 **「관리자에서 직접 수정된
|
||||||
|
것으로 보임」 배너**(external)를 띄운다. 「다시 확인」은 「다시 읽기」와 같다.
|
||||||
|
- 적용·기본정보·상태 등 **모든 쓰기는 유효 현재값과 비교**해 바뀐 것만 보낸다. 이걸
|
||||||
|
안 하면 "A→B 로 바꾼 직후 B→A" 가 카페24의 지연된 값(A)과 같다고 판단돼 무시된다.
|
||||||
|
- 쓰기 뒤 화면은 **PUT 응답으로 그린다.** 응답은 쓰기 직후의 실제 값이다(GET 과 달리
|
||||||
|
지연이 없다 — 실물 예제 응답에 `updated_date` 가 갱신되어 온다). 다시 GET 하지 않는다.
|
||||||
|
- 스냅샷은 마이그레이션 `cafe24_db_004_write_snapshot.sql`(멱등)의 두 컬럼
|
||||||
|
(`last_write_snapshot` JSONB, `last_written_at`)을 쓴다. 미적용 상태에서는 스냅샷
|
||||||
|
저장이 실패해 로그가 남지만 적용 자체는 성공한다 — 반드시 적용할 것.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3-4. 정보 패널 API 사실 (카페24 문서 실물 예제 기준)
|
||||||
|
|
||||||
|
- **이미지 업로드**: `POST /admin/products/images` body `{"requests":[{"image":"<base64>"}]}`
|
||||||
|
→ `{"images":[{"path":"https://{domain}/web/upload/NNEditor/…"}]}`. 1장 10MB, 1호출 30MB,
|
||||||
|
1회 20장. 상품 대표이미지는 그 경로를 `PUT /admin/products/{no}` 의 `detail_image` 에
|
||||||
|
넣고 `image_upload_type: "A"`(대표이미지등록 — 목록/작은목록/축소를 카페24가 리사이징).
|
||||||
|
`B` 는 네 이미지를 각각 지정, `C` 는 웹FTP.
|
||||||
|
- **가격**: `price`(판매가) · `supply_price`(공급가, 문서상 "참조 목적") · `retail_price`
|
||||||
|
(소비자가). 문자열 `"11000.00"` 형식으로 보낸다(`store.parse_price`). 쇼핑몰 설정의
|
||||||
|
"판매가 계산 기준"이 상품가(B)면 `price` 대신 `price_excluding_tax` 를 써야 한다는 문서
|
||||||
|
주석이 있다 — 그 경우 카페24 오류 메시지가 그대로 화면에 뜬다.
|
||||||
|
- **옵션**: `GET/POST/PUT/DELETE /admin/products/{no}/options`, 응답 루트 `option`.
|
||||||
|
생성은 조합형(`option_type:"T"`, `option_list_type:"S"`) + 옵션값 목록 → **품목 자동
|
||||||
|
생성**. 수정(PUT)은 `original_options`(수정 전)와 `options`(수정 후)를 **같은 순서·개수**로
|
||||||
|
짝지어 보낸다 — 옵션명·옵션값 이름·`option_image_file`(옵션 버튼 이미지)·
|
||||||
|
`option_link_image`(연결 이미지)·`option_color`·`option_display_type`(S 셀렉트/P 미리보기/
|
||||||
|
B 버튼/R 라디오)만 바꿀 수 있고 **옵션값 추가/삭제는 불가**(문서). 연동형(E)은
|
||||||
|
`option_code`/`value_no`/`option_preset_code` 를 함께 보낸다. DELETE 는 옵션 사용안함 +
|
||||||
|
**품목 전부 삭제**(화면에서 확인창).
|
||||||
|
- **품목**: `GET /admin/products/{no}/variants` → `variants[]`(`variant_code` 12자,
|
||||||
|
`options[{name,value}]`, `custom_variant_code`, `additional_amount`, `display`, `selling`,
|
||||||
|
`quantity`, `image`…). `PUT /admin/products/{no}/variants` body `{"shop_no":1,
|
||||||
|
"requests":[{variant_code, custom_variant_code, additional_amount, display, selling…}]}`
|
||||||
|
1회 100건(초과 시 나눠 보냄). 재고(`quantity`)는 이 화면에서 건드리지 않는다.
|
||||||
|
- "옵션별 상품 이름" 은 옵션값(`option_text`)이고, "옵션별 썸네일" 은 `option_image_file`
|
||||||
|
(+ `option_link_image` 에도 같은 경로) 이며, "옵션별 상품코드/추가금액/진열" 은 품목
|
||||||
|
(`custom_variant_code`/`additional_amount`/`display`)이다 — 카페24 모델상 둘은 다른
|
||||||
|
리소스라 화면에서도 「옵션 저장」과 「품목 저장」이 따로 있다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -441,6 +533,14 @@ docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db
|
|||||||
docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_003_swiper_revisions.sql
|
docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_003_swiper_revisions.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
|
읽기 지연 보정(3-3)·정보 패널을 쓰려면 마이그레이션 004 를 적용해야 한다(멱등).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_004_write_snapshot.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
선택 env: `CAFE24_READ_LAG_GRACE_MIN`(기본 360) — 3-3 의 유예시간(분).
|
||||||
|
|
||||||
worker 가 도는지 확인:
|
worker 가 도는지 확인:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -474,6 +574,8 @@ DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노
|
|||||||
| 6 | 자동 종료/복원 · 롤백 | ✖ 되돌리기는 쓰지 않기로 결정. 버전 선택 복원만 남음 |
|
| 6 | 자동 종료/복원 · 롤백 | ✖ 되돌리기는 쓰지 않기로 결정. 버전 선택 복원만 남음 |
|
||||||
| 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | ✖ 일괄수정은 사용하지 않기로 제거. 필요해지면 다시 논의 |
|
| 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | ✖ 일괄수정은 사용하지 않기로 제거. 필요해지면 다시 논의 |
|
||||||
| 8 | 디자인 보관함 파일 편집(모바일 스와이프 · PC/모바일 상품상세 템플릿) — FTP | ✅ 완료. 예약 적용은 없음(상품 전용 개념이라 범위 밖) |
|
| 8 | 디자인 보관함 파일 편집(모바일 스와이프 · PC/모바일 상품상세 템플릿) — FTP | ✅ 완료. 예약 적용은 없음(상품 전용 개념이라 범위 밖) |
|
||||||
|
| 9 | 카페24 읽기 지연 보정("마지막 쓰기가 권위", 3-3) | ✅ 완료 (마이그레이션 004) |
|
||||||
|
| 10 | 상품 정보 패널 — 상품명/가격 · 대표이미지 · 옵션/품목 (3-4) | ✅ 완료 |
|
||||||
|
|
||||||
Phase 5 의 worker 는 `app/modules/cafe24/worker.py` 에 둔다 — `Dockerfile` 이
|
Phase 5 의 worker 는 `app/modules/cafe24/worker.py` 에 둔다 — `Dockerfile` 이
|
||||||
`COPY app/ ./app/` 만 하므로 `scripts/` 에 두면 이미지에 포함되지 않는다.
|
`COPY app/ ./app/` 만 하므로 `scripts/` 에 두면 이미지에 포함되지 않는다.
|
||||||
|
|||||||
+3
-1
@@ -303,7 +303,7 @@ DDL: `scripts/sql/cafe24_db_init.sql` (멱등). DB·역할(`cafe24_app`)·테이
|
|||||||
| 테이블 | 용도 |
|
| 테이블 | 용도 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `cafe24_oauth_tokens` | 쇼핑몰별 OAuth 토큰(`mall_id` UNIQUE). access/refresh 는 **Fernet 암호문**으로 저장. 상품관리 + 향후 주문관리가 공유 |
|
| `cafe24_oauth_tokens` | 쇼핑몰별 OAuth 토큰(`mall_id` UNIQUE). access/refresh 는 **Fernet 암호문**으로 저장. 상품관리 + 향후 주문관리가 공유 |
|
||||||
| `cafe24_products` | 상품 캐시(`product_no` UNIQUE). 목록/검색 속도용이며 source of truth 는 언제나 카페24 |
|
| `cafe24_products` | 상품 캐시(`product_no` UNIQUE). 목록/검색 속도용이며 source of truth 는 언제나 카페24. `last_write_snapshot`(JSONB, 마이그레이션 004) 에 카페24 PUT 응답을 섹션별(`product`/`options`/`variants`)로 남겨 **읽기 지연**(PUT 뒤 GET 이 한동안 예전 값을 돌려줌) 동안 화면 기준으로 쓴다 |
|
||||||
| `cafe24_product_revisions` | 상세페이지 HTML 버전(append-only). `revision_type` SYNC/DRAFT/**BACKUP**/MANUAL/SCHEDULED/ROLLBACK |
|
| `cafe24_product_revisions` | 상세페이지 HTML 버전(append-only). `revision_type` SYNC/DRAFT/**BACKUP**/MANUAL/SCHEDULED/ROLLBACK |
|
||||||
| `cafe24_product_schedules` | 예약 작업. `status` PENDING/PROCESSING/SUCCESS/FAILED/CANCELLED, 재시도·자동종료·복원 대상 포함 |
|
| `cafe24_product_schedules` | 예약 작업. `status` PENDING/PROCESSING/SUCCESS/FAILED/CANCELLED, 재시도·자동종료·복원 대상 포함 |
|
||||||
| `cafe24_audit_logs` | 누가 무엇을 바꿨나. worker 수행분은 `actor='SCHEDULER'` |
|
| `cafe24_audit_logs` | 누가 무엇을 바꿨나. worker 수행분은 `actor='SCHEDULER'` |
|
||||||
@@ -315,6 +315,8 @@ DDL: `scripts/sql/cafe24_db_init.sql` (멱등). DB·역할(`cafe24_app`)·테이
|
|||||||
- 예약의 `restore_revision_id` 는 **예약 실행 순간** 만든 BACKUP 을 가리킨다(예약 생성 시점 값이 아님).
|
- 예약의 `restore_revision_id` 는 **예약 실행 순간** 만든 BACKUP 을 가리킨다(예약 생성 시점 값이 아님).
|
||||||
- 일괄 예약은 상품 1건당 1행 + 공통 `parent_job_id` — 한 상품 실패가 나머지를 막지 않는다.
|
- 일괄 예약은 상품 1건당 1행 + 공통 `parent_job_id` — 한 상품 실패가 나머지를 막지 않는다.
|
||||||
- 토큰 암호화 키는 `.env` 의 `CAFE24_TOKEN_SECRET`. **값을 바꾸면 기존 토큰을 복호화할 수 없어 카페24 재연결이 필요하다.**
|
- 토큰 암호화 키는 `.env` 의 `CAFE24_TOKEN_SECRET`. **값을 바꾸면 기존 토큰을 복호화할 수 없어 카페24 재연결이 필요하다.**
|
||||||
|
- 기존 운영 DB 에는 마이그레이션 002·003·004 를 순서대로 적용한다(전부 멱등):
|
||||||
|
`docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_004_write_snapshot.sql`
|
||||||
|
|
||||||
### 운영 서버 초기화 (1회, 사용자 승인 후)
|
### 운영 서버 초기화 (1회, 사용자 승인 후)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- cafe24_db 마이그레이션 004 — 쓰기 직후 스냅샷 (카페24 읽기 지연 보정)
|
||||||
|
--
|
||||||
|
-- 카페24 관리자 API 는 PUT 직후 한동안 GET 에서 예전 값을 돌려준다.
|
||||||
|
-- PUT 응답의 상품 값(상품명·가격·이미지·진열/판매·updated_date)을 여기 남겨,
|
||||||
|
-- GET 의 updated_date 가 스냅샷보다 이전이면 스냅샷으로 화면을 덮어씌운다.
|
||||||
|
-- 상세설명(HTML)은 cafe24_product_revisions 가 담당하므로 넣지 않는다.
|
||||||
|
--
|
||||||
|
-- 적용:
|
||||||
|
-- docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_004_write_snapshot.sql
|
||||||
|
-- 멱등 — 여러 번 실행해도 안전.
|
||||||
|
|
||||||
|
ALTER TABLE cafe24_products
|
||||||
|
ADD COLUMN IF NOT EXISTS last_write_snapshot JSONB,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_written_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN cafe24_products.last_write_snapshot IS
|
||||||
|
'카페24 PUT 응답 상품 값(상품명·가격·이미지·진열/판매·updated_date). 읽기 지연 보정용';
|
||||||
|
COMMENT ON COLUMN cafe24_products.last_written_at IS
|
||||||
|
'마지막으로 카페24에 쓴 시각. 유예시간(CAFE24_READ_LAG_GRACE_MIN) 판정용';
|
||||||
@@ -94,9 +94,15 @@ CREATE TABLE IF NOT EXISTS cafe24_products (
|
|||||||
display BOOLEAN NOT NULL DEFAULT TRUE,
|
display BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
selling BOOLEAN NOT NULL DEFAULT TRUE,
|
selling BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
last_synced_at TIMESTAMPTZ,
|
last_synced_at TIMESTAMPTZ,
|
||||||
|
-- 쓰기 직후 스냅샷(카페24 읽기 지연 보정, 마이그레이션 004)
|
||||||
|
last_write_snapshot JSONB,
|
||||||
|
last_written_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
ALTER TABLE cafe24_products
|
||||||
|
ADD COLUMN IF NOT EXISTS last_write_snapshot JSONB,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_written_at TIMESTAMPTZ;
|
||||||
CREATE INDEX IF NOT EXISTS idx_cafe24_products_name ON cafe24_products (product_name);
|
CREATE INDEX IF NOT EXISTS idx_cafe24_products_name ON cafe24_products (product_name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_cafe24_products_code ON cafe24_products (product_code);
|
CREATE INDEX IF NOT EXISTS idx_cafe24_products_code ON cafe24_products (product_code);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user