Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f71ef4d71 | |||
| 45807e545d | |||
| 47e09b94d7 | |||
| e7db2a90fc | |||
| ae7daabae5 | |||
| ead1361790 | |||
| 2de019a203 | |||
| fe344f9fda | |||
| 354eebeaac | |||
| 07f71d1df0 | |||
| c36ba70a16 | |||
| 5c54c99f7e | |||
| 414c47e24d | |||
| a23cbd7b67 | |||
| aab5157386 | |||
| 127f396366 | |||
| c6ee37ee48 | |||
| c72c0710ad | |||
| 7eb63ff664 | |||
| 55d1c1d6f3 | |||
| 739bb80b04 | |||
| 8cfe963526 | |||
| a58fa7b83b | |||
| 024653dc8e | |||
| 233c8db631 | |||
| 3537e058b0 | |||
| 5127600a3d | |||
| 67c28ae1c7 | |||
| 86914f0bc6 | |||
| 0862c5572b | |||
| 8bdf8695f9 | |||
| 8d93d09dac | |||
| 469de15998 | |||
| 8ec38db6d1 | |||
| 6dae0e45c9 | |||
| b2a70ad0ab | |||
| 8e3e2d1f76 | |||
| 39dc28d664 | |||
| 34476ba611 | |||
| 9b9bafdf95 | |||
| 3522fc2119 | |||
| 25ed369583 | |||
| e96c0636f5 | |||
| a1e222acb9 | |||
| 87fca22ea0 | |||
| 08ebe7b53d | |||
| b60690d3e1 | |||
| 5284fb1c6e | |||
| 1d3dac3bec | |||
| 119a128ee0 | |||
| 7a2e933c16 | |||
| 07626bcaf8 | |||
| 4be7c7f580 | |||
| c6fb8ed375 |
@@ -64,6 +64,34 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
|
|||||||
# 알림 수신자(쉼표구분). 비워두면 ERP 관리자(admin) 전원에게 발송.
|
# 알림 수신자(쉼표구분). 비워두면 ERP 관리자(admin) 전원에게 발송.
|
||||||
# PROJECT_NOTIFY_EMAIL=king@dbxcorp.co.kr
|
# PROJECT_NOTIFY_EMAIL=king@dbxcorp.co.kr
|
||||||
|
|
||||||
|
# ─── 카페24 상품 상세페이지 관리 모듈 (cafe24_db) ───
|
||||||
|
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
|
||||||
|
# DB/역할/스키마 생성: scripts/sql/cafe24_db_init.sql 참고.
|
||||||
|
# 권한키: cafe24(접근). admin 은 항상 통과. 카페24 연결(OAuth)은 admin 전용.
|
||||||
|
# CAFE24_DB_URL=postgresql://cafe24_app:replace-me@postgres-db:5432/cafe24_db
|
||||||
|
#
|
||||||
|
# 카페24 개발자센터(https://developers.cafe24.com)에서 앱을 만들고 발급받은 값.
|
||||||
|
# - Redirect URI 는 아래 CAFE24_REDIRECT_URI 와 반드시 동일하게 앱에 등록.
|
||||||
|
# - Scope 는 상품관리에 mall.read_product, mall.write_product 가 필요하다.
|
||||||
|
# (향후 주문관리 추가 시 mall.read_order, mall.write_order 를 앱에 추가하고
|
||||||
|
# 재인증하면 된다 — 코드는 app/integrations/cafe24/config.py 의 SCOPES)
|
||||||
|
# CAFE24_MALL_ID=miraskitchen
|
||||||
|
# CAFE24_CLIENT_ID=
|
||||||
|
# CAFE24_CLIENT_SECRET=
|
||||||
|
# CAFE24_REDIRECT_URI=https://dbx.no1king.freeddns.org/cafe24/oauth/callback
|
||||||
|
# CAFE24_API_VERSION=2026-03-01
|
||||||
|
#
|
||||||
|
# 고객이 보는 쇼핑몰 주소. 상품관리 화면에서 상세페이지 다이렉트 주소를 만들 때 쓴다
|
||||||
|
# (예: https://miras.co.kr/product/detail.html?product_no=119).
|
||||||
|
# 커스텀 도메인은 mall_id 로 알 수 없어 직접 지정해야 한다.
|
||||||
|
# 미설정 시 카페24 기본 도메인(https://<mall_id>.cafe24.com)으로 대체된다.
|
||||||
|
# CAFE24_SHOP_URL=https://miras.co.kr
|
||||||
|
#
|
||||||
|
# access/refresh token 을 DB 에 Fernet 암호화해서 저장할 때 쓰는 키.
|
||||||
|
# openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어
|
||||||
|
# 카페24 재연결(재인증)이 필요하다.
|
||||||
|
# CAFE24_TOKEN_SECRET=
|
||||||
|
|
||||||
# ─── 상품 검색 (itemcode_db 읽기 전용) ───
|
# ─── 상품 검색 (itemcode_db 읽기 전용) ───
|
||||||
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
|
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
|
||||||
# 미설정 시 검색 비활성 → 수동 등록만 가능.
|
# 미설정 시 검색 비활성 → 수동 등록만 가능.
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ __pycache__/
|
|||||||
|
|
||||||
# dispatch 라벨 PDF 샘플(실제 고객 PII — 커밋 금지)
|
# dispatch 라벨 PDF 샘플(실제 고객 PII — 커밋 금지)
|
||||||
docs/samples/
|
docs/samples/
|
||||||
|
tests/js/.out/
|
||||||
|
|||||||
@@ -35,6 +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`
|
||||||
- 프로젝트 관리 (`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`.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""외부 서비스 연동 공통 계층.
|
||||||
|
|
||||||
|
모듈(app/modules/*)에 종속되지 않는 재사용 가능한 API 클라이언트를 둔다.
|
||||||
|
현재: cafe24 (상품관리 + 향후 주문관리가 공유).
|
||||||
|
"""
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""카페24 연동 공통 계층 (상품관리 + 향후 주문관리 공유).
|
||||||
|
|
||||||
|
구성
|
||||||
|
config.py 환경변수 → Cafe24Config (하드코딩 금지)
|
||||||
|
crypto.py 토큰 Fernet 암복호화
|
||||||
|
oauth.py 인증 URL / code→token / refresh
|
||||||
|
tokens.py TokenService — 저장·만료판정·자동갱신(행 잠금)
|
||||||
|
client.py Cafe24Client — 전송·재시도·429/5xx·API 로그
|
||||||
|
products.py 상품 엔드포인트 래퍼
|
||||||
|
errors.py 공통 예외
|
||||||
|
|
||||||
|
사용 예 (모듈 라우터에서):
|
||||||
|
|
||||||
|
from app.integrations.cafe24 import build_cafe24_api
|
||||||
|
|
||||||
|
api = build_cafe24_api(store) # store = Cafe24Store
|
||||||
|
html = products.get_description(api.client, 123)
|
||||||
|
|
||||||
|
CAFE24_* 환경변수가 없어도 import 는 성공한다. 실제 호출 시점에
|
||||||
|
Cafe24ConfigError 가 나며, 라우터가 "설정 필요" 안내를 보여준다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import products
|
||||||
|
from .client import Cafe24Client
|
||||||
|
from .config import (
|
||||||
|
DEFAULT_SCOPES,
|
||||||
|
DESIGN_SCOPES,
|
||||||
|
ORDER_SCOPES,
|
||||||
|
PRODUCT_SCOPES,
|
||||||
|
Cafe24Config,
|
||||||
|
load_config,
|
||||||
|
)
|
||||||
|
from .errors import (
|
||||||
|
Cafe24ApiError,
|
||||||
|
Cafe24AuthError,
|
||||||
|
Cafe24ConfigError,
|
||||||
|
Cafe24Error,
|
||||||
|
Cafe24RateLimitError,
|
||||||
|
)
|
||||||
|
from .oauth import TokenBundle, build_authorize_url, exchange_code, new_state, refresh_tokens
|
||||||
|
from .tokens import TokenService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Cafe24Api",
|
||||||
|
"build_cafe24_api",
|
||||||
|
"Cafe24Client",
|
||||||
|
"Cafe24Config",
|
||||||
|
"TokenService",
|
||||||
|
"TokenBundle",
|
||||||
|
"load_config",
|
||||||
|
"build_authorize_url",
|
||||||
|
"exchange_code",
|
||||||
|
"refresh_tokens",
|
||||||
|
"new_state",
|
||||||
|
"products",
|
||||||
|
"PRODUCT_SCOPES",
|
||||||
|
"ORDER_SCOPES",
|
||||||
|
"DESIGN_SCOPES",
|
||||||
|
"DEFAULT_SCOPES",
|
||||||
|
"Cafe24Error",
|
||||||
|
"Cafe24ConfigError",
|
||||||
|
"Cafe24AuthError",
|
||||||
|
"Cafe24RateLimitError",
|
||||||
|
"Cafe24ApiError",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Cafe24Api:
|
||||||
|
"""설정 + 토큰서비스 + 클라이언트 묶음. 라우터/worker 가 이것만 들고 다닌다."""
|
||||||
|
|
||||||
|
config: Cafe24Config
|
||||||
|
tokens: TokenService
|
||||||
|
client: Cafe24Client
|
||||||
|
|
||||||
|
|
||||||
|
def build_cafe24_api(store: Any, *, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> Cafe24Api:
|
||||||
|
"""Cafe24Store 를 저장소로 쓰는 API 묶음 생성.
|
||||||
|
|
||||||
|
store 는 토큰 3개 메서드(get_token_row/save_token_row/token_lock)와
|
||||||
|
API 로그 기록용 log_api_call 을 제공해야 한다.
|
||||||
|
"""
|
||||||
|
config = load_config(scopes=scopes)
|
||||||
|
token_service = TokenService(store, config)
|
||||||
|
client = Cafe24Client(
|
||||||
|
config,
|
||||||
|
token_service,
|
||||||
|
api_logger=getattr(store, "log_api_call", None),
|
||||||
|
)
|
||||||
|
return Cafe24Api(config=config, tokens=token_service, client=client)
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""카페24 Admin API 전송 계층.
|
||||||
|
|
||||||
|
라우터/서비스는 httpx 를 직접 쓰지 않고 이 클라이언트만 쓴다.
|
||||||
|
여기서 처리하는 것:
|
||||||
|
- Authorization 헤더 부착 (TokenService 가 만료 시 자동 갱신)
|
||||||
|
- X-Cafe24-Api-Version 헤더
|
||||||
|
- timeout
|
||||||
|
- 401 → 토큰 1회 강제 갱신 후 재시도
|
||||||
|
- 429 → Retry-After 존중, 제한 횟수만큼 대기 후 재시도
|
||||||
|
- 5xx / 네트워크 오류 → 지수 백오프 재시도
|
||||||
|
- 호출당 최소 간격 유지(대량 작업이 한 번에 몰리지 않게)
|
||||||
|
- API 로그 기록 (토큰/시크릿은 절대 기록하지 않음)
|
||||||
|
|
||||||
|
동기(sync) 클라이언트다. 예약 worker 가 평범한 스크립트이고, 라우터에서는
|
||||||
|
`async def` 대신 `def` 핸들러로 선언해 FastAPI 의 스레드풀에서 실행하면
|
||||||
|
이벤트 루프를 막지 않는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import Cafe24Config
|
||||||
|
from .errors import (
|
||||||
|
Cafe24ApiError,
|
||||||
|
Cafe24AuthError,
|
||||||
|
Cafe24ConfigError,
|
||||||
|
Cafe24RateLimitError,
|
||||||
|
)
|
||||||
|
from .tokens import TokenService
|
||||||
|
|
||||||
|
logger = logging.getLogger("cafe24.client")
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 30.0
|
||||||
|
DEFAULT_MAX_RETRIES = 3
|
||||||
|
# 카페24 호출 사이 최소 간격(초). 대량 수정 시 429 를 미리 피한다.
|
||||||
|
DEFAULT_MIN_INTERVAL = 0.35
|
||||||
|
|
||||||
|
# api_logger(endpoint, method, product_no, http_status, result, error_message, duration_ms)
|
||||||
|
ApiLogger = Callable[..., None]
|
||||||
|
|
||||||
|
|
||||||
|
class Cafe24Client:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: Cafe24Config,
|
||||||
|
token_service: TokenService,
|
||||||
|
*,
|
||||||
|
api_logger: ApiLogger | None = None,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
max_retries: int = DEFAULT_MAX_RETRIES,
|
||||||
|
min_interval: float = DEFAULT_MIN_INTERVAL,
|
||||||
|
):
|
||||||
|
self._config = config
|
||||||
|
self._tokens = token_service
|
||||||
|
self._api_logger = api_logger
|
||||||
|
self._timeout = timeout
|
||||||
|
self._max_retries = max_retries
|
||||||
|
self._min_interval = min_interval
|
||||||
|
self._pace_lock = threading.Lock()
|
||||||
|
self._last_call = 0.0
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 내부 헬퍼
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
def _pace(self) -> None:
|
||||||
|
"""호출 간 최소 간격 확보 (스레드 안전)."""
|
||||||
|
with self._pace_lock:
|
||||||
|
gap = time.monotonic() - self._last_call
|
||||||
|
if gap < self._min_interval:
|
||||||
|
time.sleep(self._min_interval - gap)
|
||||||
|
self._last_call = time.monotonic()
|
||||||
|
|
||||||
|
def _headers(self, access_token: str) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Cafe24-Api-Version": self._config.api_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _log(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
endpoint: str,
|
||||||
|
method: str,
|
||||||
|
product_no: int | None,
|
||||||
|
http_status: int | None,
|
||||||
|
result: str,
|
||||||
|
error_message: str,
|
||||||
|
duration_ms: int,
|
||||||
|
) -> None:
|
||||||
|
if self._api_logger is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._api_logger(
|
||||||
|
endpoint=endpoint,
|
||||||
|
method=method,
|
||||||
|
product_no=product_no,
|
||||||
|
http_status=http_status,
|
||||||
|
result=result,
|
||||||
|
error_message=error_message[:500],
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 — 로그 실패가 본 작업을 막으면 안 된다.
|
||||||
|
logger.exception("카페24 API 로그 기록 실패")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _error_message(response: httpx.Response) -> str:
|
||||||
|
"""카페24 오류 응답에서 사람이 읽을 메시지만 뽑는다."""
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError:
|
||||||
|
return response.text[:500]
|
||||||
|
error = payload.get("error")
|
||||||
|
if isinstance(error, dict):
|
||||||
|
parts = [str(error.get("message") or "")]
|
||||||
|
detail = error.get("details")
|
||||||
|
if isinstance(detail, list) and detail:
|
||||||
|
parts.append("; ".join(str(d.get("message", d)) for d in detail[:3]))
|
||||||
|
message = " / ".join(p for p in parts if p)
|
||||||
|
if message:
|
||||||
|
return message[:500]
|
||||||
|
return str(payload)[:500]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _retry_after(response: httpx.Response, *, attempt: int) -> float:
|
||||||
|
raw = (response.headers.get("Retry-After") or "").strip()
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
return max(0.5, float(raw))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return min(8.0, 0.5 * (2**attempt))
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 공개 API
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
product_no: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환."""
|
||||||
|
if not self._config.configured:
|
||||||
|
raise Cafe24ConfigError(
|
||||||
|
"카페24 설정이 없습니다. 미설정 항목: " + ", ".join(self._config.missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
endpoint = path if path.startswith("/") else f"/{path}"
|
||||||
|
url = f"{self._config.api_base}{endpoint}"
|
||||||
|
method = method.upper()
|
||||||
|
forced_refresh = False
|
||||||
|
last_error: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(self._max_retries + 1):
|
||||||
|
self._pace()
|
||||||
|
started = time.monotonic()
|
||||||
|
status: int | None = None
|
||||||
|
try:
|
||||||
|
access_token = self._tokens.get_access_token()
|
||||||
|
with httpx.Client(timeout=self._timeout) as client:
|
||||||
|
response = client.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers=self._headers(access_token),
|
||||||
|
params=params,
|
||||||
|
json=json,
|
||||||
|
)
|
||||||
|
status = response.status_code
|
||||||
|
elapsed = int((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
if 200 <= status < 300:
|
||||||
|
self._log(
|
||||||
|
endpoint=endpoint, method=method, product_no=product_no,
|
||||||
|
http_status=status, result="SUCCESS", error_message="",
|
||||||
|
duration_ms=elapsed,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except ValueError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
message = self._error_message(response)
|
||||||
|
self._log(
|
||||||
|
endpoint=endpoint, method=method, product_no=product_no,
|
||||||
|
http_status=status, result="FAIL", error_message=message,
|
||||||
|
duration_ms=elapsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
if status == 401 and not forced_refresh:
|
||||||
|
# 서버가 토큰을 먼저 무효화한 경우 — 1회만 강제 갱신 후 재시도.
|
||||||
|
forced_refresh = True
|
||||||
|
self._tokens.force_expire()
|
||||||
|
last_error = Cafe24AuthError("카페24 인증이 만료되어 갱신 후 재시도합니다.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if status == 401:
|
||||||
|
raise Cafe24AuthError(
|
||||||
|
"카페24 인증에 실패했습니다. 시스템 → 카페24 연결에서 재인증하세요.",
|
||||||
|
needs_reauth=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if status == 429:
|
||||||
|
wait = self._retry_after(response, attempt=attempt)
|
||||||
|
last_error = Cafe24RateLimitError(
|
||||||
|
f"카페24 API 호출 제한(429). {wait:.1f}초 후 재시도합니다.",
|
||||||
|
retry_after=wait,
|
||||||
|
)
|
||||||
|
if attempt >= self._max_retries:
|
||||||
|
raise last_error
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
|
||||||
|
error = Cafe24ApiError(message, status=status, endpoint=endpoint)
|
||||||
|
if error.retryable and attempt < self._max_retries:
|
||||||
|
last_error = error
|
||||||
|
time.sleep(min(8.0, 0.5 * (2**attempt)))
|
||||||
|
continue
|
||||||
|
raise error
|
||||||
|
|
||||||
|
except (Cafe24AuthError, Cafe24RateLimitError, Cafe24ApiError, Cafe24ConfigError):
|
||||||
|
raise
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
elapsed = int((time.monotonic() - started) * 1000)
|
||||||
|
message = f"네트워크 오류 ({type(exc).__name__})"
|
||||||
|
self._log(
|
||||||
|
endpoint=endpoint, method=method, product_no=product_no,
|
||||||
|
http_status=status, result="ERROR", error_message=message,
|
||||||
|
duration_ms=elapsed,
|
||||||
|
)
|
||||||
|
last_error = Cafe24ApiError(message, status=0, endpoint=endpoint)
|
||||||
|
if attempt < self._max_retries:
|
||||||
|
time.sleep(min(8.0, 0.5 * (2**attempt)))
|
||||||
|
continue
|
||||||
|
raise last_error from None
|
||||||
|
|
||||||
|
# 재시도를 모두 소진 (401 강제갱신 루프 포함)
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
raise Cafe24ApiError("카페24 API 호출에 실패했습니다.", endpoint=endpoint)
|
||||||
|
|
||||||
|
def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
return self.request("GET", path, **kwargs)
|
||||||
|
|
||||||
|
def put(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
return self.request("PUT", path, **kwargs)
|
||||||
|
|
||||||
|
def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
return self.request("POST", path, **kwargs)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""카페24 연동 설정 — 환경변수만 읽는다(하드코딩 금지).
|
||||||
|
|
||||||
|
app/main.py 의 env() 헬퍼와 동일하게 os.getenv + strip 규칙을 쓴다.
|
||||||
|
integrations 계층은 app.main 을 import 하지 않는다(순환 import 방지).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# 상품관리에 필요한 최소 scope. 향후 주문관리는 ORDER_SCOPES 를 더한다.
|
||||||
|
PRODUCT_SCOPES: tuple[str, ...] = ("mall.read_product", "mall.write_product")
|
||||||
|
ORDER_SCOPES: tuple[str, ...] = ("mall.read_order", "mall.write_order")
|
||||||
|
|
||||||
|
# 디자인(테마) — **요청하지 않는다.** 아래 확인 결과 때문이다.
|
||||||
|
#
|
||||||
|
# 운영몰에서 직접 호출해 확인한 사실(2026-08-14):
|
||||||
|
# GET /admin/themes → 조회는 가능(디자인 권한 필요)하지만 테마 "목록"뿐이며
|
||||||
|
# 스킨 파일 내용은 응답에 없다.
|
||||||
|
# GET /admin/themes/pages → No API found. (버전 2026-03-01 에 존재하지 않음)
|
||||||
|
# 스킨 HTML 파일(product/detail.html 등)을 읽거나 쓰는 엔드포인트는 없다.
|
||||||
|
#
|
||||||
|
# 즉 스킨에 박힌 값(예: 상단 공통 홍보를 제외할 상품번호 목록)을 API 로 고치는
|
||||||
|
# 방법은 없다. 스킨은 카페24 관리자에서 직접 관리해야 한다.
|
||||||
|
#
|
||||||
|
# 페이지에 코드를 주입하는 유일한 수단은 스크립트 태그(/admin/scripttags)이며,
|
||||||
|
# 디자인이 아니라 **mall.write_store(상점)** 권한이 필요하고 인라인 코드가 아닌
|
||||||
|
# 외부 HTTPS URL 만 받는다. 쓰려면 우리 서버에 공개 JS 엔드포인트가 필요하다.
|
||||||
|
DESIGN_SCOPES: tuple[str, ...] = ("mall.read_design", "mall.write_design")
|
||||||
|
STORE_SCOPES: tuple[str, ...] = ("mall.read_store", "mall.write_store")
|
||||||
|
|
||||||
|
# 실제로 요청하는 scope 묶음. 개발자센터 앱에 등록된 권한과 어긋나면 인증이
|
||||||
|
# 거부되므로, 여기에 추가할 때는 앱 권한도 함께 확인해야 한다.
|
||||||
|
# 쓰지 않는 권한은 요청하지 않는다 — 토큰이 유출돼도 피해 범위를 좁히기 위함.
|
||||||
|
DEFAULT_SCOPES: tuple[str, ...] = PRODUCT_SCOPES
|
||||||
|
|
||||||
|
DEFAULT_API_VERSION = "2026-03-01"
|
||||||
|
|
||||||
|
|
||||||
|
def _env(name: str, default: str = "") -> str:
|
||||||
|
value = os.getenv(name, "").strip()
|
||||||
|
return value if value else default
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Cafe24Config:
|
||||||
|
mall_id: str
|
||||||
|
client_id: str
|
||||||
|
client_secret: str
|
||||||
|
redirect_uri: str
|
||||||
|
api_version: str
|
||||||
|
token_secret: str
|
||||||
|
scopes: tuple[str, ...]
|
||||||
|
# 쇼핑몰 표시 주소(고객이 보는 도메인). 커스텀 도메인은 mall_id 로 알 수 없어
|
||||||
|
# 환경변수로 받는다. 미설정 시 카페24 기본 도메인으로 대체한다.
|
||||||
|
shop_url: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
"""OAuth 를 시작할 수 있는 최소 조건."""
|
||||||
|
return bool(self.mall_id and self.client_id and self.client_secret and self.redirect_uri)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def missing(self) -> list[str]:
|
||||||
|
"""설정 안내 화면에 표시할 미설정 환경변수 이름들."""
|
||||||
|
pairs = (
|
||||||
|
("CAFE24_MALL_ID", self.mall_id),
|
||||||
|
("CAFE24_CLIENT_ID", self.client_id),
|
||||||
|
("CAFE24_CLIENT_SECRET", self.client_secret),
|
||||||
|
("CAFE24_REDIRECT_URI", self.redirect_uri),
|
||||||
|
("CAFE24_TOKEN_SECRET", self.token_secret),
|
||||||
|
)
|
||||||
|
return [name for name, value in pairs if not value]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_base(self) -> str:
|
||||||
|
return f"https://{self.mall_id}.cafe24api.com/api/v2"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scope_param(self) -> str:
|
||||||
|
return ",".join(self.scopes)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shop_base(self) -> str:
|
||||||
|
"""고객이 보는 쇼핑몰 주소(끝 슬래시 없음).
|
||||||
|
|
||||||
|
`CAFE24_SHOP_URL` 이 없으면 카페24 기본 도메인을 쓴다 — 커스텀 도메인을
|
||||||
|
모르더라도 항상 유효한 주소가 나온다.
|
||||||
|
"""
|
||||||
|
url = (self.shop_url or "").strip().rstrip("/")
|
||||||
|
if url:
|
||||||
|
return url if "://" in url else f"https://{url}"
|
||||||
|
return f"https://{self.mall_id}.cafe24.com" if self.mall_id else ""
|
||||||
|
|
||||||
|
def product_url(self, product_no: int | str) -> str:
|
||||||
|
"""상품 상세페이지 다이렉트 주소."""
|
||||||
|
base = self.shop_base
|
||||||
|
return f"{base}/product/detail.html?product_no={product_no}" if base else ""
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(*, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> Cafe24Config:
|
||||||
|
"""환경변수에서 설정을 읽는다. 값이 없어도 예외를 던지지 않는다.
|
||||||
|
|
||||||
|
미설정 판단은 호출부가 `configured` / `missing` 으로 한다
|
||||||
|
(앱 기동을 막지 않기 위해 — 다른 모듈과 동일한 정책).
|
||||||
|
"""
|
||||||
|
return Cafe24Config(
|
||||||
|
mall_id=_env("CAFE24_MALL_ID"),
|
||||||
|
client_id=_env("CAFE24_CLIENT_ID"),
|
||||||
|
client_secret=_env("CAFE24_CLIENT_SECRET"),
|
||||||
|
redirect_uri=_env("CAFE24_REDIRECT_URI"),
|
||||||
|
api_version=_env("CAFE24_API_VERSION", DEFAULT_API_VERSION),
|
||||||
|
token_secret=_env("CAFE24_TOKEN_SECRET"),
|
||||||
|
scopes=scopes,
|
||||||
|
shop_url=_env("CAFE24_SHOP_URL"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""토큰 암호화 — Fernet(AES-128-CBC + HMAC).
|
||||||
|
|
||||||
|
DB 덤프가 유출돼도 access/refresh token 이 평문으로 남지 않게 한다.
|
||||||
|
키는 .env 의 CAFE24_TOKEN_SECRET 하나이며, 임의 길이 문자열을 받아
|
||||||
|
SHA-256 으로 32바이트를 만든 뒤 Fernet 키 형식으로 변환한다
|
||||||
|
(운영자가 `openssl rand -hex 32` 같은 익숙한 방식을 그대로 쓰게 하려는 것).
|
||||||
|
|
||||||
|
⚠️ CAFE24_TOKEN_SECRET 을 바꾸면 기존 저장 토큰은 복호화할 수 없다.
|
||||||
|
그 경우 관리자 화면에서 카페24 재연결(재인증)을 하면 된다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from .errors import Cafe24ConfigError
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet(secret: str):
|
||||||
|
from cryptography.fernet import Fernet # 지연 import
|
||||||
|
|
||||||
|
if not (secret or "").strip():
|
||||||
|
raise Cafe24ConfigError(
|
||||||
|
"CAFE24_TOKEN_SECRET 환경변수가 설정되지 않았습니다. "
|
||||||
|
"openssl rand -hex 32 로 값을 만들어 .env 에 넣고 컨테이너를 재기동하세요."
|
||||||
|
)
|
||||||
|
digest = hashlib.sha256(secret.strip().encode("utf-8")).digest()
|
||||||
|
return Fernet(base64.urlsafe_b64encode(digest))
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(value: str, *, secret: str) -> str:
|
||||||
|
"""평문 → 암호문. 빈 문자열은 그대로 둔다(미연결 상태 표현)."""
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
return _fernet(secret).encrypt(value.encode("utf-8")).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(value: str, *, secret: str) -> str:
|
||||||
|
"""암호문 → 평문. 키가 바뀌었거나 손상되면 Cafe24ConfigError."""
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
from cryptography.fernet import InvalidToken # 지연 import
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _fernet(secret).decrypt(value.encode("ascii")).decode("utf-8")
|
||||||
|
except InvalidToken:
|
||||||
|
raise Cafe24ConfigError(
|
||||||
|
"저장된 카페24 토큰을 복호화하지 못했습니다. "
|
||||||
|
"CAFE24_TOKEN_SECRET 이 변경되었을 수 있습니다. 카페24 재연결이 필요합니다."
|
||||||
|
) from None
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""카페24 연동 공통 예외.
|
||||||
|
|
||||||
|
라우터/서비스는 httpx 예외를 직접 다루지 않고 여기 정의된 타입만 잡는다.
|
||||||
|
모든 메시지는 사용자에게 그대로 노출될 수 있으므로 토큰/시크릿을 담지 않는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class Cafe24Error(Exception):
|
||||||
|
"""카페24 연동 최상위 예외."""
|
||||||
|
|
||||||
|
|
||||||
|
class Cafe24ConfigError(Cafe24Error):
|
||||||
|
"""CAFE24_* 환경변수 미설정 등 설정 문제."""
|
||||||
|
|
||||||
|
|
||||||
|
class Cafe24AuthError(Cafe24Error):
|
||||||
|
"""인증 실패 — 토큰 없음/만료/refresh 불가. 재인증이 필요하다."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, needs_reauth: bool = False):
|
||||||
|
super().__init__(message)
|
||||||
|
self.needs_reauth = needs_reauth
|
||||||
|
|
||||||
|
|
||||||
|
class Cafe24RateLimitError(Cafe24Error):
|
||||||
|
"""429 Too Many Requests. retry_after 초 뒤 재시도 가능."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, retry_after: float = 1.0):
|
||||||
|
super().__init__(message)
|
||||||
|
self.retry_after = retry_after
|
||||||
|
|
||||||
|
|
||||||
|
class Cafe24ApiError(Cafe24Error):
|
||||||
|
"""그 외 API 오류(4xx/5xx). status 로 재시도 가능 여부를 판단한다."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status: int = 0, endpoint: str = ""):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
self.endpoint = endpoint
|
||||||
|
|
||||||
|
@property
|
||||||
|
def retryable(self) -> bool:
|
||||||
|
"""5xx 와 타임아웃(status=0)만 재시도 대상. 4xx 는 고쳐야 할 요청."""
|
||||||
|
return self.status == 0 or self.status >= 500
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""카페24 OAuth 2.0 (Authorization Code) — URL 생성 / 토큰 발급 / 갱신.
|
||||||
|
|
||||||
|
토큰 저장은 여기서 하지 않는다(tokens.TokenService 담당). 이 모듈은 순수하게
|
||||||
|
카페24 인증 엔드포인트와만 대화한다.
|
||||||
|
|
||||||
|
카페24 토큰 응답의 만료시각(`expires_at`, `refresh_token_expires_at`)은
|
||||||
|
타임존 표기가 없는 KST 문자열이므로 KST 를 붙여 aware datetime 으로 만든다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.timezone import KST, now_kst
|
||||||
|
|
||||||
|
from .config import Cafe24Config
|
||||||
|
from .errors import Cafe24AuthError, Cafe24ConfigError
|
||||||
|
|
||||||
|
TOKEN_TIMEOUT = 20.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TokenBundle:
|
||||||
|
"""카페24가 돌려준 토큰 한 벌 (평문 — 저장 직전에 암호화된다)."""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
access_token_expires_at: datetime
|
||||||
|
refresh_token_expires_at: datetime | None
|
||||||
|
scopes: str
|
||||||
|
|
||||||
|
|
||||||
|
def new_state() -> str:
|
||||||
|
"""CSRF 방어용 state. 세션에 넣어두고 콜백에서 대조한다."""
|
||||||
|
return secrets.token_urlsafe(24)
|
||||||
|
|
||||||
|
|
||||||
|
def build_authorize_url(config: Cafe24Config, *, state: str) -> str:
|
||||||
|
if not config.configured:
|
||||||
|
raise Cafe24ConfigError(
|
||||||
|
"카페24 설정이 없습니다. 미설정 항목: " + ", ".join(config.missing)
|
||||||
|
)
|
||||||
|
query = urlencode(
|
||||||
|
{
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": config.client_id,
|
||||||
|
"redirect_uri": config.redirect_uri,
|
||||||
|
"scope": config.scope_param,
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return f"{config.api_base}/oauth/authorize?{query}"
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth_header(config: Cafe24Config) -> str:
|
||||||
|
raw = f"{config.client_id}:{config.client_secret}".encode("utf-8")
|
||||||
|
return "Basic " + base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_expiry(value: str | None, *, fallback_seconds: int) -> datetime:
|
||||||
|
"""'2026-08-20T14:00:00.000' → KST aware datetime. 실패 시 fallback."""
|
||||||
|
text = (value or "").strip()
|
||||||
|
if text:
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=KST)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return now_kst() + timedelta(seconds=fallback_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_bundle(payload: dict) -> TokenBundle:
|
||||||
|
access = (payload.get("access_token") or "").strip()
|
||||||
|
refresh = (payload.get("refresh_token") or "").strip()
|
||||||
|
if not access:
|
||||||
|
raise Cafe24AuthError("카페24 응답에 access_token 이 없습니다.", needs_reauth=True)
|
||||||
|
|
||||||
|
scopes = payload.get("scopes")
|
||||||
|
if isinstance(scopes, list):
|
||||||
|
scope_text = ",".join(str(s) for s in scopes)
|
||||||
|
else:
|
||||||
|
scope_text = str(scopes or "")
|
||||||
|
|
||||||
|
return TokenBundle(
|
||||||
|
access_token=access,
|
||||||
|
refresh_token=refresh,
|
||||||
|
# access token 은 통상 2시간, refresh token 은 2주.
|
||||||
|
access_token_expires_at=_parse_expiry(payload.get("expires_at"), fallback_seconds=7200),
|
||||||
|
refresh_token_expires_at=(
|
||||||
|
_parse_expiry(payload.get("refresh_token_expires_at"), fallback_seconds=1209600)
|
||||||
|
if refresh
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
scopes=scope_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _post_token(config: Cafe24Config, data: dict[str, str]) -> TokenBundle:
|
||||||
|
url = f"{config.api_base}/oauth/token"
|
||||||
|
headers = {
|
||||||
|
"Authorization": _basic_auth_header(config),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=TOKEN_TIMEOUT) as client:
|
||||||
|
response = client.post(url, headers=headers, data=data)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
# 예외 문자열에 Authorization 헤더가 들어가지 않도록 타입명만 남긴다.
|
||||||
|
raise Cafe24AuthError(f"카페24 인증 서버에 연결하지 못했습니다. ({type(exc).__name__})") from None
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
# 400/401 = 코드/리프레시토큰 무효 → 재인증 필요.
|
||||||
|
raise Cafe24AuthError(
|
||||||
|
f"카페24 토큰 요청이 거부되었습니다. (HTTP {response.status_code})",
|
||||||
|
needs_reauth=response.status_code in (400, 401),
|
||||||
|
)
|
||||||
|
return _to_bundle(response.json())
|
||||||
|
|
||||||
|
|
||||||
|
def exchange_code(config: Cafe24Config, *, code: str) -> TokenBundle:
|
||||||
|
"""authorization code → 최초 토큰."""
|
||||||
|
return _post_token(
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": config.redirect_uri,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_tokens(config: Cafe24Config, *, refresh_token: str) -> TokenBundle:
|
||||||
|
"""refresh token → 새 토큰 한 벌 (refresh token 도 함께 회전된다)."""
|
||||||
|
if not (refresh_token or "").strip():
|
||||||
|
raise Cafe24AuthError("저장된 refresh token 이 없습니다.", needs_reauth=True)
|
||||||
|
return _post_token(
|
||||||
|
config,
|
||||||
|
{"grant_type": "refresh_token", "refresh_token": refresh_token},
|
||||||
|
)
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""카페24 상품 엔드포인트 래퍼.
|
||||||
|
|
||||||
|
전송/재시도/인증은 Cafe24Client 가 담당하고, 여기서는 경로와 payload 모양만
|
||||||
|
안다. 향후 주문관리는 같은 클라이언트로 `orders.py` 를 추가하면 된다.
|
||||||
|
|
||||||
|
상세설명은 **별도 리소스가 아니다.** 실제 쇼핑몰(miraskitchen)에 확인한 결과
|
||||||
|
`/admin/products/{no}/description` 은 존재하지 않는다(`No API found.`).
|
||||||
|
상세설명은 상품 리소스의 필드로 읽고 쓴다.
|
||||||
|
|
||||||
|
GET /admin/products/{no} → description · mobile_description ·
|
||||||
|
separated_mobile_description
|
||||||
|
PUT /admin/products/{no} → {"request": {"description": ...}}
|
||||||
|
|
||||||
|
목록 API(`/admin/products`) 응답에는 description 이 **없다**. 그래서 상세설명은
|
||||||
|
상품 1건씩 조회해야 한다(목록 화면에서 미리보기를 뿌리지 않는 이유).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .client import Cafe24Client
|
||||||
|
|
||||||
|
# 카페24 상품 목록 API 의 1회 최대 조회 수
|
||||||
|
PAGE_LIMIT = 100
|
||||||
|
|
||||||
|
|
||||||
|
def _flag(value: Any, *, default: bool = True) -> bool:
|
||||||
|
"""카페24는 boolean 을 'T'/'F' 문자열로 준다."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
text = str(value or "").strip().upper()
|
||||||
|
if text in ("T", "TRUE", "Y", "1"):
|
||||||
|
return True
|
||||||
|
if text in ("F", "FALSE", "N", "0"):
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def count_products(client: Cafe24Client, *, product_name: str = "") -> int:
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if product_name:
|
||||||
|
params["product_name"] = product_name
|
||||||
|
payload = client.get("/admin/products/count", params=params)
|
||||||
|
try:
|
||||||
|
return int(payload.get("count") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def list_products(
|
||||||
|
client: Cafe24Client,
|
||||||
|
*,
|
||||||
|
limit: int = PAGE_LIMIT,
|
||||||
|
offset: int = 0,
|
||||||
|
product_name: str = "",
|
||||||
|
product_no: int | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""상품 목록 1페이지. 검색어가 있으면 상품명 부분일치로 조회한다."""
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"limit": max(1, min(int(limit), PAGE_LIMIT)),
|
||||||
|
"offset": max(0, int(offset)),
|
||||||
|
}
|
||||||
|
if product_name:
|
||||||
|
params["product_name"] = product_name
|
||||||
|
if product_no:
|
||||||
|
params["product_no"] = int(product_no)
|
||||||
|
payload = client.get("/admin/products", params=params)
|
||||||
|
products = payload.get("products")
|
||||||
|
return products if isinstance(products, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_products(
|
||||||
|
client: Cafe24Client,
|
||||||
|
*,
|
||||||
|
product_name: str = "",
|
||||||
|
max_items: int = 1000,
|
||||||
|
) -> tuple[list[dict[str, Any]], bool]:
|
||||||
|
"""전체 상품을 페이지를 넘겨가며 모두 가져온다.
|
||||||
|
|
||||||
|
2분할 화면의 왼쪽 목록은 페이지 없이 한 번에 보여주고 필터·정렬을 브라우저에서
|
||||||
|
처리한다. 그래야 "진열중만" 같은 필터가 전체 기준으로 정확해진다
|
||||||
|
(한 페이지만 받아 걸러내면 다음 페이지의 해당 상품이 빠진다).
|
||||||
|
|
||||||
|
반환: (상품 목록, 상한에 걸려 잘렸는지)
|
||||||
|
상품이 max_items 를 넘으면 거기서 멈춘다 — 무한 호출로 API 제한에 걸리는
|
||||||
|
것을 막기 위한 안전장치다(현재 쇼핑몰 87개, 1회 100개 조회).
|
||||||
|
"""
|
||||||
|
collected: list[dict[str, Any]] = []
|
||||||
|
while len(collected) < max_items:
|
||||||
|
want = min(PAGE_LIMIT, max_items - len(collected))
|
||||||
|
batch = list_products(
|
||||||
|
client,
|
||||||
|
limit=want,
|
||||||
|
offset=len(collected),
|
||||||
|
product_name=product_name,
|
||||||
|
)
|
||||||
|
collected.extend(batch)
|
||||||
|
if len(batch) < want:
|
||||||
|
return collected, False # 요청한 만큼 못 받았다 = 마지막 페이지
|
||||||
|
if len(collected) >= max_items:
|
||||||
|
return collected, True # 상한에서 멈췄다 — 뒤에 더 있을 수 있다
|
||||||
|
return collected, False
|
||||||
|
|
||||||
|
|
||||||
|
def get_product(client: Cafe24Client, product_no: int) -> dict[str, Any]:
|
||||||
|
"""상품 1건 상세. 이 응답에 상세설명 필드까지 들어 있다."""
|
||||||
|
no = int(product_no)
|
||||||
|
payload = client.get(f"/admin/products/{no}", product_no=no)
|
||||||
|
product = payload.get("product")
|
||||||
|
return product if isinstance(product, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Descriptions:
|
||||||
|
"""상품 1건의 상세설명 묶음. 카페24가 언제나 source of truth 다."""
|
||||||
|
|
||||||
|
product_no: int
|
||||||
|
product_name: str
|
||||||
|
description: str
|
||||||
|
mobile_description: str
|
||||||
|
# separated_mobile_description = 'T' 면 PC/모바일 상세설명을 따로 쓴다.
|
||||||
|
# 'F' 면 모바일도 PC 값을 쓰므로 수정 시 두 필드를 함께 맞춰야 한다.
|
||||||
|
separated_mobile: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mobile_differs(self) -> bool:
|
||||||
|
return self.mobile_description != self.description
|
||||||
|
|
||||||
|
|
||||||
|
def descriptions_from_product(raw: dict[str, Any]) -> Descriptions:
|
||||||
|
"""`get_product` 응답 dict → Descriptions."""
|
||||||
|
try:
|
||||||
|
product_no = int(raw.get("product_no") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
product_no = 0
|
||||||
|
return Descriptions(
|
||||||
|
product_no=product_no,
|
||||||
|
product_name=str(raw.get("product_name") or ""),
|
||||||
|
description=str(raw.get("description") or ""),
|
||||||
|
mobile_description=str(raw.get("mobile_description") or ""),
|
||||||
|
separated_mobile=_flag(raw.get("separated_mobile_description"), default=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_descriptions(client: Cafe24Client, product_no: int) -> Descriptions:
|
||||||
|
"""상품의 현재 상세설명. 로컬 DB 의 마지막 버전을 현재값으로 가정하지 않는다."""
|
||||||
|
return descriptions_from_product(get_product(client, product_no))
|
||||||
|
|
||||||
|
|
||||||
|
def _flag_value(flag: bool) -> str:
|
||||||
|
"""카페24는 boolean 을 'T'/'F' 문자열로 받는다."""
|
||||||
|
return "T" if flag else "F"
|
||||||
|
|
||||||
|
|
||||||
|
def build_update_payload(
|
||||||
|
*,
|
||||||
|
description: str | None = None,
|
||||||
|
mobile_description: str | None = None,
|
||||||
|
product_name: str | None = None,
|
||||||
|
display: bool | None = None,
|
||||||
|
selling: bool | None = None,
|
||||||
|
shop_no: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""상품 수정 PUT body. 준 필드만 바뀌고 나머지는 유지된다(부분 수정).
|
||||||
|
|
||||||
|
`None` 인 항목은 payload 에 넣지 않는다 = 그 필드를 건드리지 않는다.
|
||||||
|
예약에서 "진열만 켜기"처럼 상세설명 없이 상태만 바꾸는 경우가 있으므로
|
||||||
|
description 도 생략할 수 있다.
|
||||||
|
"""
|
||||||
|
request: dict[str, Any] = {}
|
||||||
|
if description is not None:
|
||||||
|
request["description"] = description
|
||||||
|
if product_name is not None:
|
||||||
|
request["product_name"] = product_name
|
||||||
|
if mobile_description is not None:
|
||||||
|
request["mobile_description"] = mobile_description
|
||||||
|
if display is not None:
|
||||||
|
request["display"] = _flag_value(display)
|
||||||
|
if selling is not None:
|
||||||
|
request["selling"] = _flag_value(selling)
|
||||||
|
payload: dict[str, Any] = {"request": request}
|
||||||
|
if shop_no:
|
||||||
|
payload["shop_no"] = int(shop_no)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def update_product(
|
||||||
|
client: Cafe24Client,
|
||||||
|
product_no: int,
|
||||||
|
*,
|
||||||
|
description: str | None = None,
|
||||||
|
mobile_description: str | None = None,
|
||||||
|
product_name: str | None = None,
|
||||||
|
display: bool | None = None,
|
||||||
|
selling: bool | None = None,
|
||||||
|
shop_no: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""상품 부분 수정. 상세설명·상품명·진열·판매를 한 번의 호출로 바꿀 수 있다.
|
||||||
|
|
||||||
|
바꿀 것이 하나도 없으면 호출하지 않고 빈 dict 를 돌려준다.
|
||||||
|
|
||||||
|
⚠️ 상세설명을 바꿀 때는 쓰기 직전 카페24 현재 HTML 을 다시 읽어 BACKUP
|
||||||
|
revision 을 남길 것(`docs/CAFE24_MODULE.md` 규칙). 이 함수는 백업하지 않는다.
|
||||||
|
"""
|
||||||
|
payload = build_update_payload(
|
||||||
|
description=description,
|
||||||
|
mobile_description=mobile_description,
|
||||||
|
product_name=product_name,
|
||||||
|
display=display,
|
||||||
|
selling=selling,
|
||||||
|
shop_no=shop_no,
|
||||||
|
)
|
||||||
|
if not payload["request"]:
|
||||||
|
return {}
|
||||||
|
no = int(product_no)
|
||||||
|
response = client.put(f"/admin/products/{no}", json=payload, product_no=no)
|
||||||
|
product = response.get("product")
|
||||||
|
return product if isinstance(product, dict) else response
|
||||||
|
|
||||||
|
|
||||||
|
def update_descriptions(
|
||||||
|
client: Cafe24Client,
|
||||||
|
product_no: int,
|
||||||
|
*,
|
||||||
|
description: str,
|
||||||
|
mobile_description: str | None = None,
|
||||||
|
shop_no: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""상세설명만 교체하는 지름길. 실제 전송은 `update_product` 가 한다."""
|
||||||
|
return update_product(
|
||||||
|
client,
|
||||||
|
product_no,
|
||||||
|
description=description,
|
||||||
|
mobile_description=mobile_description,
|
||||||
|
shop_no=shop_no,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_product(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""카페24 상품 dict → 캐시 테이블(cafe24_products) 컬럼 모양으로 정규화."""
|
||||||
|
try:
|
||||||
|
product_no = int(raw.get("product_no") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
product_no = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"product_no": product_no,
|
||||||
|
"product_code": str(raw.get("product_code") or ""),
|
||||||
|
"product_name": str(raw.get("product_name") or ""),
|
||||||
|
"display": _flag(raw.get("display")),
|
||||||
|
"selling": _flag(raw.get("selling")),
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""토큰 수명 관리 — 저장/복호화/만료판정/자동 갱신.
|
||||||
|
|
||||||
|
저장소(repo)는 duck typing 으로 주입한다. 실제 구현은
|
||||||
|
`app/modules/cafe24/db.py` 의 Cafe24Store 이며, 아래 3개만 있으면 된다.
|
||||||
|
|
||||||
|
repo.get_token_row(mall_id) -> dict | None (암호문 그대로)
|
||||||
|
repo.save_token_row(**fields)-> None (UPSERT)
|
||||||
|
repo.token_lock(mall_id) -> contextmanager (FOR UPDATE, .row / .save())
|
||||||
|
|
||||||
|
`token_lock` 은 web 컨테이너와 worker 컨테이너가 동시에 refresh 를 시도해도
|
||||||
|
한쪽만 카페24에 요청하도록 행 잠금을 건다(카페24는 refresh token 을 회전시키므로
|
||||||
|
동시 refresh 시 한쪽 토큰이 무효화된다).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.timezone import KST, now_kst
|
||||||
|
|
||||||
|
from .config import Cafe24Config
|
||||||
|
from .crypto import decrypt, encrypt
|
||||||
|
from .errors import Cafe24AuthError
|
||||||
|
from .oauth import TokenBundle, refresh_tokens
|
||||||
|
|
||||||
|
logger = logging.getLogger("cafe24.tokens")
|
||||||
|
|
||||||
|
# 만료 몇 초 전부터 미리 갱신할지 (네트워크 지연 여유)
|
||||||
|
REFRESH_MARGIN = timedelta(seconds=120)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenService:
|
||||||
|
def __init__(self, repo: Any, config: Cafe24Config):
|
||||||
|
self._repo = repo
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 저장
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
def save_bundle(self, bundle: TokenBundle, *, connected_by: str = "") -> None:
|
||||||
|
"""최초 인증/재인증 후 토큰 저장. 토큰은 암호화해서 넣는다."""
|
||||||
|
secret = self._config.token_secret
|
||||||
|
self._repo.save_token_row(
|
||||||
|
mall_id=self._config.mall_id,
|
||||||
|
access_token=encrypt(bundle.access_token, secret=secret),
|
||||||
|
refresh_token=encrypt(bundle.refresh_token, secret=secret),
|
||||||
|
access_token_expires_at=bundle.access_token_expires_at,
|
||||||
|
refresh_token_expires_at=bundle.refresh_token_expires_at,
|
||||||
|
scopes=bundle.scopes,
|
||||||
|
last_refreshed_at=now_kst(),
|
||||||
|
last_error="",
|
||||||
|
connected_by=connected_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 조회
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
def _aware(self, value: Any):
|
||||||
|
"""DB 에서 온 datetime 을 KST aware 로 정규화.
|
||||||
|
|
||||||
|
컬럼이 timestamptz 라 psycopg 는 UTC 로 돌려준다. 시각 자체는 같지만
|
||||||
|
화면에 `+00:00` 으로 보이므로 KST 로 변환해 다른 모듈과 표기를 맞춘다.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
aware = value if value.tzinfo else value.replace(tzinfo=KST)
|
||||||
|
return aware.astimezone(KST)
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
"""관리자 화면용 연결 상태. 토큰 값 자체는 절대 넣지 않는다."""
|
||||||
|
if not self._config.configured:
|
||||||
|
return {
|
||||||
|
"connected": False,
|
||||||
|
"mall_id": self._config.mall_id,
|
||||||
|
"missing": self._config.missing,
|
||||||
|
"needs_reauth": False,
|
||||||
|
"reason": "환경변수 미설정",
|
||||||
|
}
|
||||||
|
|
||||||
|
row = self._repo.get_token_row(self._config.mall_id)
|
||||||
|
if not row or not row.get("access_token"):
|
||||||
|
return {
|
||||||
|
"connected": False,
|
||||||
|
"mall_id": self._config.mall_id,
|
||||||
|
"missing": self._config.missing,
|
||||||
|
"needs_reauth": True,
|
||||||
|
"reason": "아직 카페24 연결(인증)을 하지 않았습니다.",
|
||||||
|
}
|
||||||
|
|
||||||
|
access_exp = self._aware(row.get("access_token_expires_at"))
|
||||||
|
refresh_exp = self._aware(row.get("refresh_token_expires_at"))
|
||||||
|
now = now_kst()
|
||||||
|
refresh_dead = bool(refresh_exp and now >= refresh_exp)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"connected": not refresh_dead,
|
||||||
|
"mall_id": row.get("mall_id") or self._config.mall_id,
|
||||||
|
"missing": self._config.missing,
|
||||||
|
"needs_reauth": refresh_dead,
|
||||||
|
"reason": "refresh token 이 만료되었습니다. 재연결이 필요합니다." if refresh_dead else "",
|
||||||
|
"scopes": row.get("scopes") or "",
|
||||||
|
"access_token_expires_at": access_exp.isoformat(timespec="seconds") if access_exp else "",
|
||||||
|
"refresh_token_expires_at": refresh_exp.isoformat(timespec="seconds") if refresh_exp else "",
|
||||||
|
"access_expired": bool(access_exp and now >= access_exp),
|
||||||
|
"last_refreshed_at": (
|
||||||
|
self._aware(row.get("last_refreshed_at")).isoformat(timespec="seconds")
|
||||||
|
if row.get("last_refreshed_at")
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"last_error": row.get("last_error") or "",
|
||||||
|
"connected_by": row.get("connected_by") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 사용 (Cafe24Client 가 호출)
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
def get_access_token(self) -> str:
|
||||||
|
"""유효한 access token. 만료(임박)면 잠금 걸고 1회 갱신 후 반환."""
|
||||||
|
mall_id = self._config.mall_id
|
||||||
|
row = self._repo.get_token_row(mall_id)
|
||||||
|
if not row or not row.get("access_token"):
|
||||||
|
raise Cafe24AuthError(
|
||||||
|
"카페24에 연결되어 있지 않습니다. 시스템 → 카페24 연결에서 인증하세요.",
|
||||||
|
needs_reauth=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
expires_at = self._aware(row.get("access_token_expires_at"))
|
||||||
|
if expires_at and now_kst() < expires_at - REFRESH_MARGIN:
|
||||||
|
return decrypt(row["access_token"], secret=self._config.token_secret)
|
||||||
|
|
||||||
|
return self._refresh_locked(mall_id)
|
||||||
|
|
||||||
|
def force_expire(self) -> None:
|
||||||
|
"""access token 만료시각을 과거로 밀어 다음 호출에서 반드시 갱신하게 한다.
|
||||||
|
|
||||||
|
서버가 만료 전에 토큰을 무효화해 401 이 온 경우(Cafe24Client)에 쓴다.
|
||||||
|
"""
|
||||||
|
self._repo.save_token_row(
|
||||||
|
mall_id=self._config.mall_id,
|
||||||
|
access_token_expires_at=now_kst() - timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _refresh_locked(self, mall_id: str) -> str:
|
||||||
|
"""행 잠금 안에서 갱신. 잠금 대기 중 다른 프로세스가 이미 갱신했으면 그 값 사용."""
|
||||||
|
secret = self._config.token_secret
|
||||||
|
with self._repo.token_lock(mall_id) as handle:
|
||||||
|
row = handle.row
|
||||||
|
if not row:
|
||||||
|
raise Cafe24AuthError("카페24 토큰이 없습니다.", needs_reauth=True)
|
||||||
|
|
||||||
|
expires_at = self._aware(row.get("access_token_expires_at"))
|
||||||
|
if expires_at and now_kst() < expires_at - REFRESH_MARGIN:
|
||||||
|
# 잠금 대기 사이에 다른 프로세스가 갱신 완료.
|
||||||
|
return decrypt(row["access_token"], secret=secret)
|
||||||
|
|
||||||
|
refresh_exp = self._aware(row.get("refresh_token_expires_at"))
|
||||||
|
if refresh_exp and now_kst() >= refresh_exp:
|
||||||
|
handle.save(last_error="refresh token 만료 — 재인증 필요")
|
||||||
|
raise Cafe24AuthError(
|
||||||
|
"카페24 refresh token 이 만료되었습니다. 시스템 → 카페24 연결에서 재인증하세요.",
|
||||||
|
needs_reauth=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
bundle = refresh_tokens(
|
||||||
|
self._config,
|
||||||
|
refresh_token=decrypt(row.get("refresh_token") or "", secret=secret),
|
||||||
|
)
|
||||||
|
except Cafe24AuthError as exc:
|
||||||
|
handle.save(last_error=str(exc))
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("카페24 access token 갱신 완료 (mall_id=%s)", mall_id)
|
||||||
|
handle.save(
|
||||||
|
access_token=encrypt(bundle.access_token, secret=secret),
|
||||||
|
refresh_token=encrypt(bundle.refresh_token, secret=secret),
|
||||||
|
access_token_expires_at=bundle.access_token_expires_at,
|
||||||
|
refresh_token_expires_at=bundle.refresh_token_expires_at,
|
||||||
|
scopes=bundle.scopes,
|
||||||
|
last_refreshed_at=now_kst(),
|
||||||
|
last_error="",
|
||||||
|
)
|
||||||
|
return bundle.access_token
|
||||||
+19
@@ -13,6 +13,8 @@ from jinja2 import ChoiceLoader, FileSystemLoader
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
from .modules.cafe24 import build_cafe24_store
|
||||||
|
from .modules.cafe24 import router as cafe24_router
|
||||||
from .modules.cupang import build_cupang_store, build_itemcode_reader
|
from .modules.cupang import build_cupang_store, build_itemcode_reader
|
||||||
from .modules.cupang import router as cupang_router
|
from .modules.cupang import router as cupang_router
|
||||||
from .modules.dispatch import build_dispatch_store
|
from .modules.dispatch import build_dispatch_store
|
||||||
@@ -45,6 +47,7 @@ MODULE_LABELS: dict[str, str] = {
|
|||||||
"malaysia": "말레이시아 재고관리",
|
"malaysia": "말레이시아 재고관리",
|
||||||
"dispatch": "말레이시아 배송",
|
"dispatch": "말레이시아 배송",
|
||||||
"project": "프로젝트 관리",
|
"project": "프로젝트 관리",
|
||||||
|
"cafe24": "카페24 상품관리",
|
||||||
"expense_approver": "개인경비",
|
"expense_approver": "개인경비",
|
||||||
"vacation_approver": "휴가",
|
"vacation_approver": "휴가",
|
||||||
}
|
}
|
||||||
@@ -120,6 +123,7 @@ _MODULE_TEMPLATE_DIRS = [
|
|||||||
BASE_DIR / "modules" / "malaysia" / "templates",
|
BASE_DIR / "modules" / "malaysia" / "templates",
|
||||||
BASE_DIR / "modules" / "dispatch" / "templates",
|
BASE_DIR / "modules" / "dispatch" / "templates",
|
||||||
BASE_DIR / "modules" / "project" / "templates",
|
BASE_DIR / "modules" / "project" / "templates",
|
||||||
|
BASE_DIR / "modules" / "cafe24" / "templates",
|
||||||
]
|
]
|
||||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||||
templates.env.loader = ChoiceLoader(
|
templates.env.loader = ChoiceLoader(
|
||||||
@@ -170,6 +174,9 @@ app.state.dispatch_store = build_dispatch_store(dsn=env("DISPATCH_DB_URL") or No
|
|||||||
# 프로젝트 관리(아사나식): PROJECT_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
# 프로젝트 관리(아사나식): PROJECT_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||||
# 메일 알림은 SMTP_* 환경변수 기반(app/mail.py). 미설정 시 조용히 skip.
|
# 메일 알림은 SMTP_* 환경변수 기반(app/mail.py). 미설정 시 조용히 skip.
|
||||||
app.state.project_store = build_project_store(dsn=env("PROJECT_DB_URL") or None)
|
app.state.project_store = build_project_store(dsn=env("PROJECT_DB_URL") or None)
|
||||||
|
# 카페24 상품관리: CAFE24_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||||
|
# 카페24 API 호출/토큰은 app/integrations/cafe24 공통 계층(CAFE24_* 환경변수).
|
||||||
|
app.state.cafe24_store = build_cafe24_store(dsn=env("CAFE24_DB_URL") or None)
|
||||||
|
|
||||||
# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄.
|
# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄.
|
||||||
app.include_router(expense_router)
|
app.include_router(expense_router)
|
||||||
@@ -178,6 +185,7 @@ app.include_router(vacation_router)
|
|||||||
app.include_router(malaysia_router)
|
app.include_router(malaysia_router)
|
||||||
app.include_router(dispatch_router)
|
app.include_router(dispatch_router)
|
||||||
app.include_router(project_router)
|
app.include_router(project_router)
|
||||||
|
app.include_router(cafe24_router)
|
||||||
|
|
||||||
|
|
||||||
def public_url_for(request: Request, route_name: str) -> str:
|
def public_url_for(request: Request, route_name: str) -> str:
|
||||||
@@ -350,6 +358,16 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
|
|||||||
"status": "ready",
|
"status": "ready",
|
||||||
"category": "관리",
|
"category": "관리",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "cafe24",
|
||||||
|
"title": "카페24 상품관리",
|
||||||
|
"subtitle": "Cafe24 Products",
|
||||||
|
"description": "카페24 관리자에 들어가지 않고 상품 상세페이지를 편집·예약 적용하고 이전 버전으로 되돌립니다.",
|
||||||
|
"url": "/cafe24/",
|
||||||
|
"health_url": "/cafe24/health",
|
||||||
|
"status": "ready",
|
||||||
|
"category": "운영",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
allowed = allowed_modules(user_rec)
|
allowed = allowed_modules(user_rec)
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -369,6 +387,7 @@ def _icon_svg(name: str) -> str:
|
|||||||
"malaysia": '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
|
"malaysia": '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
|
||||||
"dispatch": '<rect x="1" y="3" width="15" height="13"/><path d="M16 8h4l3 3v5h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/>',
|
"dispatch": '<rect x="1" y="3" width="15" height="13"/><path d="M16 8h4l3 3v5h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/>',
|
||||||
"project": '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="13" x2="13" y2="13"/><line x1="8" y1="16" x2="11" y2="16"/>',
|
"project": '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="13" x2="13" y2="13"/><line x1="8" y1="16" x2="11" y2="16"/>',
|
||||||
|
"cafe24": '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9h10"/><path d="M7 13h7"/><path d="M7 17h4"/>',
|
||||||
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
|
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
|
||||||
}
|
}
|
||||||
body = paths.get(name, paths["modules"])
|
body = paths.get(name, paths["modules"])
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""카페24 상품 상세페이지 관리 모듈.
|
||||||
|
|
||||||
|
라우터/저장소/순수로직/템플릿을 한 디렉토리에서 관리한다.
|
||||||
|
- 라우터: `router.py` (FastAPI APIRouter, prefix=/cafe24) + `routes_*.py`
|
||||||
|
- 저장소: `db.py` (cafe24_db / PostgreSQL 전용)
|
||||||
|
- 순수 로직: `store.py` (버전/예약 상수, 재시도 규칙, 검증)
|
||||||
|
- 템플릿: `templates/cafe24/`
|
||||||
|
|
||||||
|
카페24 API 호출은 이 모듈에 두지 않는다. 향후 주문관리 모듈과 공유하기 위해
|
||||||
|
`app/integrations/cafe24/` 공통 계층을 쓴다.
|
||||||
|
|
||||||
|
데이터 저장은 cafe24_db 전용이다. CAFE24_DB_URL 미설정 시
|
||||||
|
build_cafe24_store 는 None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를
|
||||||
|
보여준다(앱은 죽지 않음).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import store
|
||||||
|
from .router import router
|
||||||
|
from .store import (
|
||||||
|
REVISION_LABELS,
|
||||||
|
REVISION_TYPES,
|
||||||
|
SCHEDULE_STATUS_LABELS,
|
||||||
|
SCHEDULE_STATUSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"router",
|
||||||
|
"store",
|
||||||
|
"REVISION_TYPES",
|
||||||
|
"REVISION_LABELS",
|
||||||
|
"SCHEDULE_STATUSES",
|
||||||
|
"SCHEDULE_STATUS_LABELS",
|
||||||
|
"build_cafe24_store",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_cafe24_store(*, dsn: str | None) -> Any:
|
||||||
|
"""CAFE24_DB_URL 이 있으면 Cafe24Store, 없으면 None.
|
||||||
|
|
||||||
|
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 표시.
|
||||||
|
"""
|
||||||
|
if not dsn:
|
||||||
|
return None
|
||||||
|
from .db import Cafe24Store # 지연 import (개발 환경 deps 없을 수 있음)
|
||||||
|
|
||||||
|
return Cafe24Store(dsn)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""카페24 모듈 공용 가드/컨텍스트 헬퍼.
|
||||||
|
|
||||||
|
router.py 와 routes_*.py 가 함께 쓴다(순환 import 방지를 위해 분리).
|
||||||
|
다른 모듈과 동일한 규칙:
|
||||||
|
- JSON API → require_user() : 401/403 HTTPException
|
||||||
|
- HTML 페이지 → guard() : 리다이렉트 / denied.html 응답 반환
|
||||||
|
app.main 은 함수 안에서 지연 import 한다(순환 import 방지).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
MODULE_KEY = "cafe24"
|
||||||
|
MODULE_NAME = "카페24 상품관리"
|
||||||
|
|
||||||
|
CONFIG_HELP = (
|
||||||
|
"카페24 모듈이 아직 설정되지 않았습니다. "
|
||||||
|
"CAFE24_DB_URL 환경변수를 설정하고 "
|
||||||
|
"scripts/sql/cafe24_db_init.sql 로 cafe24_db 를 초기화한 뒤 "
|
||||||
|
"컨테이너를 재기동하세요."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_store(request: Request) -> Any:
|
||||||
|
return getattr(request.app.state, "cafe24_store", None)
|
||||||
|
|
||||||
|
|
||||||
|
def require_user(request: Request) -> dict[str, Any]:
|
||||||
|
from app.main import get_current_user_record # noqa: WPS433
|
||||||
|
from app.store import has_module # noqa: WPS433
|
||||||
|
|
||||||
|
user = get_current_user_record(request)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||||
|
if not has_module(user, MODULE_KEY):
|
||||||
|
raise HTTPException(status_code=403, detail=f"{MODULE_NAME} 모듈 권한이 없습니다.")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(request: Request) -> dict[str, Any]:
|
||||||
|
"""카페24 연결(OAuth)·연결 해제는 관리자만."""
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
user = require_user(request)
|
||||||
|
if not is_admin(user):
|
||||||
|
raise HTTPException(status_code=403, detail="관리자만 카페24 연결을 변경할 수 있습니다.")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_store(request: Request) -> tuple[Any, dict[str, Any]]:
|
||||||
|
"""JSON API 용 — store 미설정이면 503."""
|
||||||
|
user = require_user(request)
|
||||||
|
st = get_store(request)
|
||||||
|
if st is None:
|
||||||
|
raise HTTPException(status_code=503, detail=CONFIG_HELP)
|
||||||
|
return st, user
|
||||||
|
|
||||||
|
|
||||||
|
def render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
|
||||||
|
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"denied.html",
|
||||||
|
{
|
||||||
|
"reason": CONFIG_HELP,
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"nav_items": build_erp_nav(user, active=MODULE_KEY),
|
||||||
|
},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def guard(request: Request):
|
||||||
|
"""로그인+권한+store 점검. 페이지 핸들러 진입부에서 사용.
|
||||||
|
|
||||||
|
반환이 tuple 이면 (store, user), 아니면 그대로 응답으로 돌려준다.
|
||||||
|
"""
|
||||||
|
from app.main import get_current_user_record, render_template # noqa: WPS433
|
||||||
|
from app.store import has_module, is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
user = get_current_user_record(request)
|
||||||
|
if user is None:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
if not has_module(user, MODULE_KEY):
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"denied.html",
|
||||||
|
{
|
||||||
|
"reason": f"{MODULE_NAME} 접근 권한이 없습니다.",
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
st = get_store(request)
|
||||||
|
if st is None:
|
||||||
|
return render_config_needed(request, user)
|
||||||
|
return st, user
|
||||||
|
|
||||||
|
|
||||||
|
def base_ctx(request: Request, user: dict[str, Any], *, active_tab: str = "") -> dict[str, Any]:
|
||||||
|
from app.main import build_erp_nav # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"is_super": bool(user.get("is_super_admin")),
|
||||||
|
"nav_items": build_erp_nav(user, active=MODULE_KEY),
|
||||||
|
"active_tab": active_tab,
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
"""cafe24_db PostgreSQL 저장소.
|
||||||
|
|
||||||
|
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — 다른 모듈과 동일 패턴.
|
||||||
|
- 연결 정보: 환경변수 `CAFE24_DB_URL`
|
||||||
|
(예: postgresql://cafe24_app:<pwd>@postgres-db:5432/cafe24_db)
|
||||||
|
- 스키마는 앱이 만들지 않는다. `scripts/sql/cafe24_db_init.sql` 을 superuser 가
|
||||||
|
사전 적용한다. 앱 계정(cafe24_app)은 CRUD 권한만 받는다.
|
||||||
|
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
|
||||||
|
|
||||||
|
토큰 값은 이 계층에 도달하기 전 이미 Fernet 암호문이다(평문 취급 금지).
|
||||||
|
API 로그에는 토큰/시크릿을 넣지 않는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
|
from app.timezone import KST
|
||||||
|
|
||||||
|
from . import store
|
||||||
|
|
||||||
|
logger = logging.getLogger("cafe24.db")
|
||||||
|
|
||||||
|
# save_token_row / TokenLock.save 에서 부분 갱신을 허용하는 컬럼 화이트리스트.
|
||||||
|
# 여기 없는 키는 무시한다(임의 컬럼 주입 방지).
|
||||||
|
_TOKEN_FIELDS: tuple[str, ...] = (
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
"access_token_expires_at",
|
||||||
|
"refresh_token_expires_at",
|
||||||
|
"scopes",
|
||||||
|
"last_refreshed_at",
|
||||||
|
"last_error",
|
||||||
|
"connected_by",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenLock:
|
||||||
|
"""token_lock() 이 넘겨주는 핸들. 잠긴 행 조회 + 같은 트랜잭션 안 저장."""
|
||||||
|
|
||||||
|
def __init__(self, conn: Any, mall_id: str, row: dict[str, Any] | None):
|
||||||
|
self._conn = conn
|
||||||
|
self._mall_id = mall_id
|
||||||
|
self.row = row
|
||||||
|
|
||||||
|
def save(self, **fields: Any) -> None:
|
||||||
|
_update_token_row(self._conn, self._mall_id, fields)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_token_row(conn: Any, mall_id: str, fields: dict[str, Any]) -> None:
|
||||||
|
"""UPSERT. 주어진 컬럼만 갱신한다(부분 갱신)."""
|
||||||
|
allowed = {k: v for k, v in fields.items() if k in _TOKEN_FIELDS}
|
||||||
|
if not allowed:
|
||||||
|
return
|
||||||
|
columns = list(allowed.keys())
|
||||||
|
placeholders = ", ".join(["%s"] * len(columns))
|
||||||
|
assignments = ", ".join(f"{col} = EXCLUDED.{col}" for col in columns)
|
||||||
|
conn.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO cafe24_oauth_tokens (mall_id, {", ".join(columns)})
|
||||||
|
VALUES (%s, {placeholders})
|
||||||
|
ON CONFLICT (mall_id) DO UPDATE SET {assignments}
|
||||||
|
""",
|
||||||
|
(mall_id, *[allowed[col] for col in columns]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Cafe24Store:
|
||||||
|
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||||
|
self._pool = ConnectionPool(
|
||||||
|
conninfo=dsn,
|
||||||
|
min_size=min_size,
|
||||||
|
max_size=max_size,
|
||||||
|
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||||
|
open=False,
|
||||||
|
)
|
||||||
|
self._pool.open(wait=False)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._pool.close()
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# OAuth 토큰 — app/integrations/cafe24/tokens.py 가 요구하는 3개 메서드
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def get_token_row(self, mall_id: str) -> dict[str, Any] | None:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT * FROM cafe24_oauth_tokens WHERE mall_id = %s",
|
||||||
|
(mall_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
def save_token_row(self, *, mall_id: str, **fields: Any) -> None:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
_update_token_row(conn, mall_id, fields)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def token_lock(self, mall_id: str) -> Iterator[TokenLock]:
|
||||||
|
"""토큰 행을 FOR UPDATE 로 잠근 채 작업.
|
||||||
|
|
||||||
|
web 컨테이너와 worker 컨테이너가 동시에 refresh 하는 것을 막는다
|
||||||
|
(카페24는 refresh token 을 회전시키므로 동시 갱신 시 한쪽이 무효화됨).
|
||||||
|
행이 아직 없으면 row=None 으로 넘어간다.
|
||||||
|
"""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
with conn.transaction():
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM cafe24_oauth_tokens WHERE mall_id = %s FOR UPDATE",
|
||||||
|
(mall_id,),
|
||||||
|
).fetchone()
|
||||||
|
yield TokenLock(conn, mall_id, row)
|
||||||
|
|
||||||
|
def disconnect(self, mall_id: str) -> None:
|
||||||
|
"""연결 해제 — 토큰만 지운다(이력/예약은 보존)."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
conn.execute("DELETE FROM cafe24_oauth_tokens WHERE mall_id = %s", (mall_id,))
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# API 호출 로그 (Cafe24Client 가 주입받아 호출)
|
||||||
|
# ⚠️ Authorization/토큰/시크릿은 절대 기록하지 않는다.
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def log_api_call(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
endpoint: str,
|
||||||
|
method: str,
|
||||||
|
product_no: int | None,
|
||||||
|
http_status: int | None,
|
||||||
|
result: str,
|
||||||
|
error_message: str,
|
||||||
|
duration_ms: int,
|
||||||
|
) -> None:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cafe24_api_logs
|
||||||
|
(endpoint, method, product_no, http_status, result, error_message, duration_ms)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
""",
|
||||||
|
(endpoint, method, product_no, http_status, result, error_message, duration_ms),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_api_logs(self, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM cafe24_api_logs
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(max(1, min(int(limit), 500)),),
|
||||||
|
).fetchall()
|
||||||
|
return [self._serialize(r) for r in rows]
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 작업 감사 로그
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def log_audit(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
actor: str,
|
||||||
|
action: str,
|
||||||
|
product_no: int | None = None,
|
||||||
|
revision_id: int | None = None,
|
||||||
|
schedule_id: int | None = None,
|
||||||
|
result: str = "",
|
||||||
|
detail: str = "",
|
||||||
|
) -> None:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
self._insert_audit(
|
||||||
|
conn,
|
||||||
|
actor=actor,
|
||||||
|
action=action,
|
||||||
|
product_no=product_no,
|
||||||
|
revision_id=revision_id,
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
result=result,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _insert_audit(
|
||||||
|
conn: Any,
|
||||||
|
*,
|
||||||
|
actor: str,
|
||||||
|
action: str,
|
||||||
|
product_no: int | None = None,
|
||||||
|
revision_id: int | None = None,
|
||||||
|
schedule_id: int | None = None,
|
||||||
|
result: str = "",
|
||||||
|
detail: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""호출자의 트랜잭션에 합류시키기 위해 conn 을 받는 정적 헬퍼."""
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cafe24_audit_logs
|
||||||
|
(actor, action, product_no, revision_id, schedule_id, result, detail)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
""",
|
||||||
|
(actor, action, product_no, revision_id, schedule_id, result, detail[:1000]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_audit_logs(self, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM cafe24_audit_logs
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(max(1, min(int(limit), 500)),),
|
||||||
|
).fetchall()
|
||||||
|
return [self._serialize(r) for r in rows]
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 상품 캐시
|
||||||
|
# source of truth 는 언제나 카페24다. 이 표는 목록 조회 결과를 담아두는
|
||||||
|
# 곳이며, 예약·로그 화면에서 API 호출 없이 상품명을 보여줄 때 쓴다.
|
||||||
|
# 상세설명(HTML)은 여기 넣지 않는다(cafe24_product_revisions 담당).
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def upsert_products(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
"""정규화된 상품 dict 목록(products.normalize_product 결과)을 UPSERT."""
|
||||||
|
valid = [r for r in rows if int(r.get("product_no") or 0) > 0]
|
||||||
|
if not valid:
|
||||||
|
return 0
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO cafe24_products
|
||||||
|
(product_no, product_code, product_name, display, selling, last_synced_at)
|
||||||
|
VALUES (%s,%s,%s,%s,%s, now())
|
||||||
|
ON CONFLICT (product_no) DO UPDATE SET
|
||||||
|
product_code = EXCLUDED.product_code,
|
||||||
|
product_name = EXCLUDED.product_name,
|
||||||
|
display = EXCLUDED.display,
|
||||||
|
selling = EXCLUDED.selling,
|
||||||
|
last_synced_at = now()
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
int(r["product_no"]),
|
||||||
|
str(r.get("product_code") or ""),
|
||||||
|
str(r.get("product_name") or ""),
|
||||||
|
bool(r.get("display", True)),
|
||||||
|
bool(r.get("selling", True)),
|
||||||
|
)
|
||||||
|
for r in valid
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return len(valid)
|
||||||
|
|
||||||
|
def get_cached_product(self, product_no: int) -> dict[str, Any]:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM cafe24_products WHERE product_no = %s",
|
||||||
|
(int(product_no),),
|
||||||
|
).fetchone()
|
||||||
|
return self._serialize(row)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다)
|
||||||
|
# 쓰기 직전 BACKUP 을 남기는 것이 유일한 복구 수단이다.
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def add_revision(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
product_no: int,
|
||||||
|
html_content: str,
|
||||||
|
revision_type: str,
|
||||||
|
memo: str = "",
|
||||||
|
created_by: str = "",
|
||||||
|
) -> int:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cafe24_product_revisions
|
||||||
|
(product_no, html_content, revision_type, memo, created_by)
|
||||||
|
VALUES (%s,%s,%s,%s,%s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
int(product_no),
|
||||||
|
html_content or "",
|
||||||
|
store.normalize_revision_type(revision_type),
|
||||||
|
(memo or "")[:500],
|
||||||
|
created_by or "",
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["id"]) if row else 0
|
||||||
|
|
||||||
|
def list_revisions(self, product_no: int, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
"""버전 목록. html_content 는 수 MB 일 수 있어 길이만 계산해서 준다."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, product_no, revision_type, memo, created_by, created_at,
|
||||||
|
length(html_content) AS html_length
|
||||||
|
FROM cafe24_product_revisions
|
||||||
|
WHERE product_no = %s
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(int(product_no), max(1, min(int(limit), 200))),
|
||||||
|
).fetchall()
|
||||||
|
return [self._serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def get_revision(self, revision_id: int) -> dict[str, Any]:
|
||||||
|
"""버전 1건 전체(HTML 포함). 복원/비교용."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM cafe24_product_revisions WHERE id = %s",
|
||||||
|
(int(revision_id),),
|
||||||
|
).fetchone()
|
||||||
|
return self._serialize(row)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 예약 (지정 시각에 상세페이지·진열/판매 적용)
|
||||||
|
# 되돌리기는 쓰지 않으므로 end_* 컬럼은 건드리지 않는다.
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def create_schedule(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
product_no: int,
|
||||||
|
scheduled_at: datetime,
|
||||||
|
revision_id: int | None,
|
||||||
|
set_display: bool | None,
|
||||||
|
set_selling: bool | None,
|
||||||
|
memo: str = "",
|
||||||
|
created_by: str = "",
|
||||||
|
) -> int:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cafe24_product_schedules
|
||||||
|
(product_no, scheduled_at, revision_id, set_display, set_selling,
|
||||||
|
memo, created_by)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
int(product_no),
|
||||||
|
scheduled_at,
|
||||||
|
revision_id,
|
||||||
|
set_display,
|
||||||
|
set_selling,
|
||||||
|
(memo or "")[:500],
|
||||||
|
created_by or "",
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["id"]) if row else 0
|
||||||
|
|
||||||
|
def list_schedules(self, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||||
|
"""예약 목록. 대기 중인 것을 먼저, 그다음 최근 처리 순."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT s.*, p.product_name,
|
||||||
|
(s.revision_id IS NOT NULL) AS has_html
|
||||||
|
FROM cafe24_product_schedules s
|
||||||
|
LEFT JOIN cafe24_products p ON p.product_no = s.product_no
|
||||||
|
ORDER BY (s.status = 'PENDING') DESC,
|
||||||
|
CASE WHEN s.status = 'PENDING' THEN s.scheduled_at END ASC,
|
||||||
|
s.scheduled_at DESC, s.id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(max(1, min(int(limit), 500)),),
|
||||||
|
).fetchall()
|
||||||
|
return [self._serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def cancel_schedule(self, schedule_id: int, *, actor: str = "") -> bool:
|
||||||
|
"""대기 중인 예약만 취소한다. 실행 중/완료된 것은 건드리지 않는다."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
with conn.transaction():
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE cafe24_product_schedules
|
||||||
|
SET status = 'CANCELLED', completed_at = now()
|
||||||
|
WHERE id = %s AND status = 'PENDING'
|
||||||
|
RETURNING id, product_no
|
||||||
|
""",
|
||||||
|
(int(schedule_id),),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
self._insert_audit(
|
||||||
|
conn,
|
||||||
|
actor=actor,
|
||||||
|
action="schedule_cancel",
|
||||||
|
product_no=row["product_no"],
|
||||||
|
schedule_id=row["id"],
|
||||||
|
result="SUCCESS",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def claim_due_schedule(self, *, now: datetime) -> Iterator[dict[str, Any] | None]:
|
||||||
|
"""실행할 예약 1건을 잡아 PROCESSING 으로 바꾼다(worker 전용).
|
||||||
|
|
||||||
|
`FOR UPDATE SKIP LOCKED` 로 잠그므로 worker 가 여러 개 떠 있어도 같은 예약을
|
||||||
|
두 번 실행하지 않는다. 재시도 대기(next_retry_at)가 남아 있으면 건너뛴다.
|
||||||
|
"""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
with conn.transaction():
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM cafe24_product_schedules
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
AND scheduled_at <= %s
|
||||||
|
AND (next_retry_at IS NULL OR next_retry_at <= %s)
|
||||||
|
ORDER BY scheduled_at ASC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
""",
|
||||||
|
(now, now),
|
||||||
|
).fetchone()
|
||||||
|
if row is not None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE cafe24_product_schedules
|
||||||
|
SET status = 'PROCESSING', started_at = now(), last_error = ''
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(row["id"],),
|
||||||
|
)
|
||||||
|
yield dict(row) if row is not None else None
|
||||||
|
|
||||||
|
def finish_schedule(
|
||||||
|
self,
|
||||||
|
schedule_id: int,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
error: str = "",
|
||||||
|
next_retry_at: datetime | None = None,
|
||||||
|
retry_count: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""예약 종료 처리. 재시도로 되돌릴 때는 status='PENDING' + next_retry_at."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE cafe24_product_schedules
|
||||||
|
SET status = %s,
|
||||||
|
last_error = %s,
|
||||||
|
next_retry_at = %s,
|
||||||
|
retry_count = COALESCE(%s, retry_count),
|
||||||
|
completed_at = CASE WHEN %s IN ('SUCCESS','FAILED','CANCELLED')
|
||||||
|
THEN now() ELSE completed_at END
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
store.normalize_schedule_status(status),
|
||||||
|
(error or "")[:1000],
|
||||||
|
next_retry_at,
|
||||||
|
retry_count,
|
||||||
|
store.normalize_schedule_status(status),
|
||||||
|
int(schedule_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 직렬화 — datetime → KST ISO, date → ISO (다른 모듈과 동일)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
@staticmethod
|
||||||
|
def _serialize(row: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
if not row:
|
||||||
|
return {}
|
||||||
|
out = dict(row)
|
||||||
|
for key, value in list(out.items()):
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
aware = value if value.tzinfo else value.replace(tzinfo=KST)
|
||||||
|
out[key] = aware.astimezone(KST).isoformat(timespec="seconds")
|
||||||
|
elif isinstance(value, date):
|
||||||
|
out[key] = value.isoformat()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Cafe24Store", "TokenLock", "store"]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""카페24 상품 상세페이지 관리 모듈 라우터.
|
||||||
|
|
||||||
|
- 경로: /cafe24
|
||||||
|
- 권한: 로그인 + `cafe24` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
|
||||||
|
카페24 연결(OAuth) 변경은 `is_admin` 만.
|
||||||
|
- 데이터: Cafe24Store (cafe24_db / PostgreSQL) 전용.
|
||||||
|
CAFE24_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내.
|
||||||
|
- 카페24 API 호출은 app/integrations/cafe24 공통 계층을 통해서만 한다.
|
||||||
|
|
||||||
|
라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에
|
||||||
|
확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다.
|
||||||
|
routes_products 상품 목록/검색 · 상세설명 조회·편집·적용
|
||||||
|
routes_schedules 예약 등록·목록·취소 (실행은 worker.py)
|
||||||
|
routes_system 연결(OAuth)·상태·API 로그·작업 로그
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from .routes_products import products_router
|
||||||
|
from .routes_schedules import schedules_router
|
||||||
|
from .routes_system import system_router
|
||||||
|
|
||||||
|
logger = logging.getLogger("cafe24.router")
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/cafe24", tags=["cafe24"])
|
||||||
|
|
||||||
|
router.include_router(products_router)
|
||||||
|
router.include_router(schedules_router)
|
||||||
|
router.include_router(system_router)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health() -> dict[str, str]:
|
||||||
|
"""포털 카드의 상태 점(dot) 용. 인증 불필요 — 상태 문자열만 반환."""
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
"""카페24 상품 화면 — 좌우 2분할(목록 | 상세페이지 편집).
|
||||||
|
|
||||||
|
화면 구성
|
||||||
|
왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬.
|
||||||
|
오른쪽 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게.
|
||||||
|
|
||||||
|
목록은 페이지를 넘겨가며 **전체**를 한 번에 받는다(`list_all_products`). 필터·정렬을
|
||||||
|
브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음
|
||||||
|
페이지에 있는 해당 상품이 빠진다.
|
||||||
|
|
||||||
|
상품을 클릭하면 오른쪽만 교체한다(`GET /products/{no}/pane` 이 편집기 조각을
|
||||||
|
돌려주고 JS 가 끼워 넣는다). 목록을 다시 불러오지 않으므로 카페24 호출이 1회로
|
||||||
|
끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크(`/cafe24/?selected=`)로 동작한다.
|
||||||
|
|
||||||
|
쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다.
|
||||||
|
카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT →
|
||||||
|
MANUAL 버전 + 감사로그
|
||||||
|
로컬 DB 의 마지막 버전을 "지금 카페24에 올라간 값"으로 가정하지 않는다.
|
||||||
|
|
||||||
|
PC/모바일은 구분하지 않는다 — 적용 시 `description` 과 `mobile_description` 에
|
||||||
|
같은 HTML 을 쓴다(운영 방침). 분리 사용 상품이어도 한쪽만 바뀌는 일이 없다.
|
||||||
|
|
||||||
|
이미지 경로의 한글은 카페24에 퍼센트 인코딩으로 저장돼 있다. 편집기에는
|
||||||
|
`store.decode_html_urls` 로 풀어서 보여주고, 저장할 때 `encode_html_urls` 로
|
||||||
|
되돌린다(왕복 보존 — store.py 주석 참고).
|
||||||
|
|
||||||
|
핸들러는 `async def` 가 아니라 `def`(동기)로 선언한다. 카페24 API·DB 호출이
|
||||||
|
블로킹이므로 FastAPI 스레드풀에서 돌게 두는 편이 이벤트 루프를 막지 않는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Form, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
|
||||||
|
|
||||||
|
from . import store
|
||||||
|
from .common import base_ctx, guard, require_store
|
||||||
|
|
||||||
|
logger = logging.getLogger("cafe24.products")
|
||||||
|
|
||||||
|
products_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _checked(request: Request, name: str) -> bool:
|
||||||
|
"""체크박스 → bool. 값이 무엇이든 파라미터가 있으면 체크된 것으로 본다."""
|
||||||
|
return request.query_params.get(name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _no_store(response: Any) -> Any:
|
||||||
|
"""브라우저가 이 응답을 재사용하지 못하게 한다.
|
||||||
|
|
||||||
|
편집기는 카페24의 **현재** HTML 을 보여줘야 한다. 캐시된 화면이 다시 그려지면
|
||||||
|
카페24 관리자에서 값을 바꾼 뒤에도 예전 소스가 보이고, 그것을 그대로 편집하면
|
||||||
|
남의 수정을 덮어쓴다. 조각을 가져가는 fetch 에도 `cache: "no-store"` 를 건다.
|
||||||
|
"""
|
||||||
|
response.headers["Cache-Control"] = "no-store, must-revalidate"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _price(value: Any) -> str:
|
||||||
|
"""카페24 가격('6900.00') → 화면 표기('6,900원').
|
||||||
|
|
||||||
|
소수점 아래는 버린다 — 이 쇼핑몰은 원 단위라 언제나 .00 이고, 화면에 보일
|
||||||
|
이유가 없다. 숫자로 못 읽으면 받은 값을 그대로 보여준다.
|
||||||
|
"""
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
won = int(Decimal(text))
|
||||||
|
except (ArithmeticError, ValueError):
|
||||||
|
return text
|
||||||
|
return f"{won:,}원"
|
||||||
|
|
||||||
|
|
||||||
|
def _short_dt(value: Any) -> str:
|
||||||
|
"""'2026-08-14T11:38:18+09:00' → '2026-08-14 11:38'."""
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return text.replace("T", " ")[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _row_for_list(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""왼쪽 목록에 쓸 필드만 — 상품번호·상품명·진열·판매·최근수정."""
|
||||||
|
normalized = products.normalize_product(raw)
|
||||||
|
return {
|
||||||
|
"product_no": normalized["product_no"],
|
||||||
|
"product_name": normalized["product_name"],
|
||||||
|
"display": normalized["display"],
|
||||||
|
"selling": normalized["selling"],
|
||||||
|
"updated_date": _short_dt(raw.get("updated_date")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 필터 폼이 제출됐음을 알리는 표식.
|
||||||
|
# 체크박스는 해제 상태면 아무 값도 보내지 않으므로, 이것 없이는 "첫 방문"과
|
||||||
|
# "사용자가 일부러 해제함"을 구분할 수 없다(기본값이 체크라서 해제가 무시된다).
|
||||||
|
_FILTER_MARK = "f"
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_flags(request: Request) -> tuple[bool, bool]:
|
||||||
|
"""(진열중만, 판매중만). 첫 방문이면 둘 다 기본 체크로 본다."""
|
||||||
|
if request.query_params.get(_FILTER_MARK) is None:
|
||||||
|
return True, True
|
||||||
|
return _checked(request, "display"), _checked(request, "selling")
|
||||||
|
|
||||||
|
|
||||||
|
def _list_query(request: Request, *, selected: int | None = None) -> str:
|
||||||
|
"""현재 검색·필터를 유지한 목록 URL 쿼리스트링."""
|
||||||
|
params: list[tuple[str, str]] = []
|
||||||
|
keyword = (request.query_params.get("q") or "").strip()
|
||||||
|
if keyword:
|
||||||
|
params.append(("q", keyword))
|
||||||
|
if request.query_params.get(_FILTER_MARK) is not None:
|
||||||
|
# 해제 상태까지 그대로 이어지도록 표식을 함께 남긴다.
|
||||||
|
params.append((_FILTER_MARK, "1"))
|
||||||
|
for flag in ("display", "selling"):
|
||||||
|
if _checked(request, flag):
|
||||||
|
params.append((flag, "1"))
|
||||||
|
if selected:
|
||||||
|
params.append(("selected", str(selected)))
|
||||||
|
return urlencode(params)
|
||||||
|
|
||||||
|
|
||||||
|
def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]:
|
||||||
|
"""오른쪽 편집기 조각에 필요한 컨텍스트. 전체 페이지와 조각이 함께 쓴다."""
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
product: dict[str, Any] = {}
|
||||||
|
desc = None
|
||||||
|
error = ""
|
||||||
|
try:
|
||||||
|
product = products.get_product(api.client, product_no)
|
||||||
|
desc = products.descriptions_from_product(product)
|
||||||
|
st.upsert_products([products.normalize_product(product)])
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
error = str(exc)
|
||||||
|
logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc)
|
||||||
|
|
||||||
|
info = products.normalize_product(product) if product else {}
|
||||||
|
return {
|
||||||
|
"product_no": product_no,
|
||||||
|
# 고객이 보는 상세페이지 주소 (CAFE24_SHOP_URL, 없으면 카페24 기본 도메인)
|
||||||
|
"product_url": api.config.product_url(product_no),
|
||||||
|
"info": {
|
||||||
|
**info,
|
||||||
|
"price": _price(product.get("price")),
|
||||||
|
"updated_date": _short_dt(product.get("updated_date")),
|
||||||
|
"summary_description": product.get("summary_description") or "",
|
||||||
|
},
|
||||||
|
"desc": desc,
|
||||||
|
# 편집기에는 (1) 이미지 경로의 %EC%9A%A9… 을 한글로 풀고
|
||||||
|
# (2) 태그마다 줄을 나눠 정리해서 보여준다.
|
||||||
|
# 저장할 때 같은 정리를 거친 값을 카페24에 쓴다(화면과 저장값이 같다).
|
||||||
|
"html_pc": store.format_html(store.decode_html_urls(desc.description)) if desc else "",
|
||||||
|
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로).
|
||||||
|
"fingerprint": store.fingerprint(desc.description) if desc else "",
|
||||||
|
"revisions": st.list_revisions(product_no, limit=20),
|
||||||
|
"editor_error": error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@products_router.get("/", response_class=HTMLResponse)
|
||||||
|
def product_list(request: Request) -> HTMLResponse:
|
||||||
|
"""2분할 화면. `selected` 가 있으면 오른쪽 편집기까지 서버에서 그린다."""
|
||||||
|
from app.main import render_template # noqa: WPS433
|
||||||
|
|
||||||
|
checked = guard(request)
|
||||||
|
if not isinstance(checked, tuple):
|
||||||
|
return checked
|
||||||
|
st, user = checked
|
||||||
|
|
||||||
|
keyword = (request.query_params.get("q") or "").strip()
|
||||||
|
only_display, only_selling = _filter_flags(request)
|
||||||
|
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
total = 0
|
||||||
|
truncated = False
|
||||||
|
error = ""
|
||||||
|
try:
|
||||||
|
raw_rows, truncated = products.list_all_products(api.client, product_name=keyword)
|
||||||
|
st.upsert_products([products.normalize_product(r) for r in raw_rows])
|
||||||
|
total = len(raw_rows)
|
||||||
|
rows = [_row_for_list(r) for r in raw_rows]
|
||||||
|
# 필터는 전체를 받아온 뒤 적용한다(문서에 없는 API 파라미터에 기대지 않는다).
|
||||||
|
if only_display:
|
||||||
|
rows = [r for r in rows if r["display"]]
|
||||||
|
if only_selling:
|
||||||
|
rows = [r for r in rows if r["selling"]]
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
# 미연결/토큰만료/호출제한 모두 여기로 온다. 화면은 살려두고 사유만 알린다.
|
||||||
|
error = str(exc)
|
||||||
|
logger.warning("카페24 상품 목록 조회 실패: %s", exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
selected = int(request.query_params.get("selected") or 0)
|
||||||
|
except ValueError:
|
||||||
|
selected = 0
|
||||||
|
|
||||||
|
ctx = base_ctx(request, user, active_tab="products")
|
||||||
|
ctx.update(
|
||||||
|
{
|
||||||
|
"page_title": "카페24 상품관리",
|
||||||
|
"page_subtitle": "상품 상세페이지 조회·편집·예약",
|
||||||
|
"rows": rows,
|
||||||
|
"total": total,
|
||||||
|
"shown": len(rows),
|
||||||
|
"truncated": truncated,
|
||||||
|
"keyword": keyword,
|
||||||
|
"only_display": only_display,
|
||||||
|
"only_selling": only_selling,
|
||||||
|
"selected": selected,
|
||||||
|
"list_query": _list_query(request),
|
||||||
|
"error": error,
|
||||||
|
"flash": request.query_params.get("msg", ""),
|
||||||
|
"flash_error": request.query_params.get("err", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if selected:
|
||||||
|
ctx.update(_editor_ctx(st, selected))
|
||||||
|
return _no_store(render_template(request, "cafe24/products.html", ctx))
|
||||||
|
|
||||||
|
|
||||||
|
@products_router.get("/products/{product_no}/pane", response_class=HTMLResponse)
|
||||||
|
def product_pane(request: Request, product_no: int) -> HTMLResponse:
|
||||||
|
"""오른쪽 편집기 조각만 — 목록을 다시 그리지 않기 위해 JS 가 가져간다."""
|
||||||
|
from app.main import render_template # noqa: WPS433
|
||||||
|
|
||||||
|
checked = guard(request)
|
||||||
|
if not isinstance(checked, tuple):
|
||||||
|
return checked
|
||||||
|
st, user = checked
|
||||||
|
|
||||||
|
ctx = base_ctx(request, user, active_tab="products")
|
||||||
|
ctx.update(_editor_ctx(st, product_no))
|
||||||
|
ctx["list_query"] = _list_query(request)
|
||||||
|
return _no_store(render_template(request, "cafe24/_editor.html", ctx))
|
||||||
|
|
||||||
|
|
||||||
|
# 카페24 상품명 최대 길이(API 문서 기준). 넘기면 카페24가 거절하므로 미리 막는다.
|
||||||
|
NAME_MAX = 250
|
||||||
|
|
||||||
|
|
||||||
|
@products_router.post("/products/{product_no}/name")
|
||||||
|
def product_rename(
|
||||||
|
request: Request,
|
||||||
|
product_no: int,
|
||||||
|
payload: dict[str, Any] = Body(default_factory=dict),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""상품명만 바꾼다 — 편집기 제목 옆 연필 버튼용(JSON API).
|
||||||
|
|
||||||
|
상세설명과 마찬가지로 **쓰기 전에 카페24의 현재값을 읽는다.** 여기서는 되돌릴
|
||||||
|
HTML 이 없으므로 revision 은 만들지 않고, 대신 이전 이름을 감사로그에 남긴다
|
||||||
|
(되돌리려면 로그를 보고 다시 바꾼다).
|
||||||
|
"""
|
||||||
|
st, user = require_store(request)
|
||||||
|
actor = str(user.get("email") or "")
|
||||||
|
|
||||||
|
name = str(payload.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="상품명을 입력하세요.")
|
||||||
|
if len(name) > NAME_MAX:
|
||||||
|
raise HTTPException(status_code=400, detail=f"상품명은 {NAME_MAX}자를 넘을 수 없습니다.")
|
||||||
|
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
try:
|
||||||
|
current = products.get_product(api.client, product_no)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
st.log_audit(
|
||||||
|
actor=actor, action="rename_product", product_no=product_no,
|
||||||
|
result="FAIL", detail=f"현재값 조회 실패: {exc}",
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=502, detail=f"카페24 현재값을 읽지 못했습니다: {exc}") from exc
|
||||||
|
|
||||||
|
before = str(current.get("product_name") or "")
|
||||||
|
if before == name:
|
||||||
|
return {"ok": True, "product_name": before, "changed": False}
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated = products.update_product(api.client, product_no, product_name=name)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
st.log_audit(
|
||||||
|
actor=actor, action="rename_product", product_no=product_no,
|
||||||
|
result="FAIL", detail=f"'{before}' → '{name}' 실패: {exc}",
|
||||||
|
)
|
||||||
|
logger.warning("카페24 상품 %s 이름 변경 실패: %s", product_no, exc)
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
info = products.normalize_product(updated) if updated else {}
|
||||||
|
if info.get("product_no"):
|
||||||
|
st.upsert_products([info])
|
||||||
|
after = str(info.get("product_name") or name)
|
||||||
|
st.log_audit(
|
||||||
|
actor=actor, action="rename_product", product_no=product_no,
|
||||||
|
result="SUCCESS", detail=f"'{before}' → '{after}'",
|
||||||
|
)
|
||||||
|
logger.info("카페24 상품 %s 이름 변경 (%s)", product_no, actor)
|
||||||
|
return {"ok": True, "product_name": after, "changed": True}
|
||||||
|
|
||||||
|
|
||||||
|
@products_router.post("/products/{product_no}/status")
|
||||||
|
def product_status(
|
||||||
|
request: Request,
|
||||||
|
product_no: int,
|
||||||
|
payload: dict[str, Any] = Body(default_factory=dict),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""진열/판매 상태만 바꾼다 — 편집기 오른쪽 위 배지 클릭용(JSON API).
|
||||||
|
|
||||||
|
상세설명은 건드리지 않는다(`build_update_payload` 는 준 필드만 보낸다). 그래서
|
||||||
|
BACKUP revision 도 만들지 않는다 — 되돌릴 HTML 이 없고, 상태는 다시 눌러
|
||||||
|
되돌릴 수 있다.
|
||||||
|
|
||||||
|
`value` 는 클라이언트가 **원하는 결과값**이다(현재값을 뒤집지 않는다). 화면의
|
||||||
|
배지가 카페24와 어긋나 있어도 사용자가 누른 대로 되는 편이 예측 가능하다.
|
||||||
|
응답에는 쓰기 후 카페24가 돌려준 실제 상태를 담아 화면을 그것에 맞춘다.
|
||||||
|
"""
|
||||||
|
st, user = require_store(request)
|
||||||
|
actor = str(user.get("email") or "")
|
||||||
|
|
||||||
|
field = str(payload.get("field") or "").strip()
|
||||||
|
if field not in ("display", "selling"):
|
||||||
|
raise HTTPException(status_code=400, detail="field 는 display 또는 selling 이어야 합니다.")
|
||||||
|
want = bool(payload.get("value"))
|
||||||
|
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
try:
|
||||||
|
updated = products.update_product(api.client, product_no, **{field: want})
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
st.log_audit(
|
||||||
|
actor=actor, action=f"set_{field}", product_no=product_no,
|
||||||
|
result="FAIL", detail=f"{want} 설정 실패: {exc}",
|
||||||
|
)
|
||||||
|
logger.warning("카페24 상품 %s %s 변경 실패: %s", product_no, field, exc)
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
# 응답이 상품 dict 면 그것이 곧 현재 상태다. 모양이 다르면(방어) 다시 조회한다.
|
||||||
|
if "display" not in updated or "selling" not in updated:
|
||||||
|
try:
|
||||||
|
updated = products.get_product(api.client, product_no)
|
||||||
|
except Cafe24Error as exc: # 쓰기는 됐다 — 화면만 요청값으로 맞춘다.
|
||||||
|
logger.warning("카페24 상품 %s 상태 재조회 실패: %s", product_no, exc)
|
||||||
|
updated = {}
|
||||||
|
|
||||||
|
info = products.normalize_product(updated) if updated else {}
|
||||||
|
if info.get("product_no"):
|
||||||
|
st.upsert_products([info])
|
||||||
|
state = {
|
||||||
|
"display": bool(info.get("display", want if field == "display" else True)),
|
||||||
|
"selling": bool(info.get("selling", want if field == "selling" else True)),
|
||||||
|
}
|
||||||
|
st.log_audit(
|
||||||
|
actor=actor, action=f"set_{field}", product_no=product_no,
|
||||||
|
result="SUCCESS", detail=f"{field}={'T' if want else 'F'}",
|
||||||
|
)
|
||||||
|
logger.info("카페24 상품 %s %s=%s (%s)", product_no, field, want, actor)
|
||||||
|
return {"ok": True, **state}
|
||||||
|
|
||||||
|
|
||||||
|
@products_router.get("/products/{product_no}")
|
||||||
|
def product_redirect(request: Request, product_no: int):
|
||||||
|
"""옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
|
||||||
|
return RedirectResponse(url=f"/cafe24/?selected={product_no}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@products_router.post("/products/{product_no}/apply")
|
||||||
|
def product_apply(
|
||||||
|
request: Request,
|
||||||
|
product_no: int,
|
||||||
|
html: str = Form(""),
|
||||||
|
base_fingerprint: str = Form(""),
|
||||||
|
memo: str = Form(""),
|
||||||
|
list_query: str = Form(""),
|
||||||
|
):
|
||||||
|
"""편집한 HTML 을 카페24에 즉시 적용한다.
|
||||||
|
|
||||||
|
순서를 지키는 것이 이 함수의 핵심이다.
|
||||||
|
1) 카페24에서 **현재** HTML 을 다시 읽는다(로컬 값을 현재값으로 믿지 않는다)
|
||||||
|
2) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단
|
||||||
|
3) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다
|
||||||
|
4) 쓰고, MANUAL 버전과 감사로그를 남긴다
|
||||||
|
|
||||||
|
PC/모바일을 구분하지 않는다 — 두 필드에 같은 HTML 을 쓴다. 분리 사용 상품의
|
||||||
|
모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 그 내용도 BACKUP 으로 남긴다.
|
||||||
|
"""
|
||||||
|
checked = guard(request)
|
||||||
|
if not isinstance(checked, tuple):
|
||||||
|
return checked
|
||||||
|
st, user = checked
|
||||||
|
|
||||||
|
actor = str(user.get("email") or "")
|
||||||
|
# 적용 후에는 검색·필터를 유지한 채 같은 상품이 선택된 화면으로 돌아온다.
|
||||||
|
base = f"/cafe24/?{list_query}" if list_query else f"/cafe24/?selected={product_no}"
|
||||||
|
back = base if f"selected={product_no}" in base else f"{base}&selected={product_no}"
|
||||||
|
|
||||||
|
# 화면에서 보던 그대로(정리된 소스)를 카페24에 반영한다. 한글 이미지 경로는
|
||||||
|
# 원래의 퍼센트 인코딩으로 되돌린다.
|
||||||
|
submitted = store.format_html(store.encode_html_urls(html or ""))
|
||||||
|
if not submitted.strip():
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"{back}&err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
try:
|
||||||
|
current = products.fetch_descriptions(api.client, product_no)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
st.log_audit(
|
||||||
|
actor=actor, action="apply_description", product_no=product_no,
|
||||||
|
result="FAIL", detail=f"현재값 조회 실패: {exc}",
|
||||||
|
)
|
||||||
|
return RedirectResponse(url=f"{back}&err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303)
|
||||||
|
|
||||||
|
backup_id = st.add_revision(
|
||||||
|
product_no=product_no,
|
||||||
|
html_content=current.description,
|
||||||
|
revision_type=store.REVISION_BACKUP,
|
||||||
|
memo="적용 직전 자동 백업",
|
||||||
|
created_by=actor,
|
||||||
|
)
|
||||||
|
# 모바일 내용이 PC 와 달랐다면 그것도 따로 남긴다. 아래에서 모바일을 PC 와 같게
|
||||||
|
# 덮어쓰므로, 백업하지 않으면 그 내용을 되찾을 방법이 없다.
|
||||||
|
if current.mobile_description and current.mobile_description != current.description:
|
||||||
|
st.add_revision(
|
||||||
|
product_no=product_no,
|
||||||
|
html_content=current.mobile_description,
|
||||||
|
revision_type=store.REVISION_BACKUP,
|
||||||
|
memo="적용 직전 자동 백업 (모바일 — PC와 달랐던 내용)",
|
||||||
|
created_by=actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
if base_fingerprint and base_fingerprint != store.fingerprint(current.description):
|
||||||
|
st.log_audit(
|
||||||
|
actor=actor, action="apply_description", product_no=product_no,
|
||||||
|
revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨",
|
||||||
|
)
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"{back}&err=편집하는 동안 카페24 값이 변경되었습니다. 새로고침해 현재 내용을 확인한 뒤 다시 적용하세요.",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
# PC/모바일을 구분하지 않는다 — 항상 같은 내용으로 함께 쓴다(운영 방침).
|
||||||
|
# 분리 사용 상품이어도 모바일에 같은 HTML 을 넣으므로 한쪽만 바뀌는 일이 없다.
|
||||||
|
mobile_html = submitted
|
||||||
|
|
||||||
|
if submitted == current.description and mobile_html == current.mobile_description:
|
||||||
|
return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
|
||||||
|
try:
|
||||||
|
products.update_descriptions(
|
||||||
|
api.client, product_no, description=submitted, mobile_description=mobile_html
|
||||||
|
)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
st.log_audit(
|
||||||
|
actor=actor, action="apply_description", product_no=product_no,
|
||||||
|
revision_id=backup_id, result="FAIL", detail=str(exc),
|
||||||
|
)
|
||||||
|
logger.warning("카페24 상품 %s 적용 실패: %s", product_no, exc)
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"{back}&err=적용에 실패했습니다: {exc} (직전 내용은 버전 {backup_id} 로 보관됨)",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
revision_id = st.add_revision(
|
||||||
|
product_no=product_no,
|
||||||
|
html_content=submitted,
|
||||||
|
revision_type=store.REVISION_MANUAL,
|
||||||
|
memo=memo,
|
||||||
|
created_by=actor,
|
||||||
|
)
|
||||||
|
st.log_audit(
|
||||||
|
actor=actor, action="apply_description", product_no=product_no,
|
||||||
|
revision_id=revision_id, result="SUCCESS",
|
||||||
|
detail=f"{len(submitted)}자 적용 (백업 {backup_id}, PC·모바일 동시 반영)",
|
||||||
|
)
|
||||||
|
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""카페24 예약관리 — 지정 시각에 상세페이지·진열/판매를 자동 적용.
|
||||||
|
|
||||||
|
되돌리기(자동 복원)는 쓰지 않는다. 예약은 "그 시각에 이 내용을 적용" 하나뿐이다.
|
||||||
|
한 예약에서 세 가지를 각각 고를 수 있다.
|
||||||
|
|
||||||
|
상세페이지 HTML 편집기에 있는 내용을 그 시각에 적용 (안 고르면 HTML 은 그대로)
|
||||||
|
진열 진열 / 미진열 / 변경 없음
|
||||||
|
판매 판매 / 중지 / 변경 없음
|
||||||
|
|
||||||
|
예약을 만들 때 편집기의 HTML 을 **DRAFT revision 으로 먼저 저장**하고 예약이 그
|
||||||
|
버전을 가리킨다. 나중에 편집기에서 내용을 더 고쳐도 예약 내용은 등록 시점 그대로다
|
||||||
|
(예약해둔 것이 조용히 바뀌면 안 된다).
|
||||||
|
|
||||||
|
실제 적용은 웹 프로세스가 아니라 **worker** 가 한다(`app/modules/cafe24/worker.py`,
|
||||||
|
compose 서비스 `dbx-cafe24-worker`). 브라우저를 닫아도 실행되어야 하기 때문이다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
|
||||||
|
|
||||||
|
from . import store
|
||||||
|
from .common import base_ctx, guard
|
||||||
|
from .routes_products import _no_store
|
||||||
|
|
||||||
|
logger = logging.getLogger("cafe24.schedules")
|
||||||
|
|
||||||
|
schedules_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@schedules_router.get("/schedules", response_class=HTMLResponse)
|
||||||
|
def schedules_page(request: Request) -> HTMLResponse:
|
||||||
|
"""예약 목록 — 대기 중인 것이 위, 그다음 최근 처리 순."""
|
||||||
|
from app.main import render_template # noqa: WPS433
|
||||||
|
|
||||||
|
checked = guard(request)
|
||||||
|
if not isinstance(checked, tuple):
|
||||||
|
return checked
|
||||||
|
st, user = checked
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for row in st.list_schedules(limit=200):
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status_label": store.SCHEDULE_STATUS_LABELS.get(row.get("status") or "", ""),
|
||||||
|
"action_label": store.describe_schedule_action(
|
||||||
|
has_html=bool(row.get("has_html")),
|
||||||
|
set_display=row.get("set_display"),
|
||||||
|
set_selling=row.get("set_selling"),
|
||||||
|
),
|
||||||
|
"editable": store.is_editable(row.get("status") or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = base_ctx(request, user, active_tab="schedules")
|
||||||
|
ctx.update(
|
||||||
|
{
|
||||||
|
"page_title": "카페24 — 예약관리",
|
||||||
|
"page_subtitle": "지정 시각에 상세페이지·진열/판매 자동 적용",
|
||||||
|
"rows": rows,
|
||||||
|
"pending": sum(1 for r in rows if r["status"] == store.STATUS_PENDING),
|
||||||
|
"flash": request.query_params.get("msg", ""),
|
||||||
|
"flash_error": request.query_params.get("err", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return _no_store(render_template(request, "cafe24/schedules.html", ctx))
|
||||||
|
|
||||||
|
|
||||||
|
@schedules_router.post("/schedules")
|
||||||
|
def schedule_create(
|
||||||
|
request: Request,
|
||||||
|
product_no: int = Form(...),
|
||||||
|
scheduled_at: str = Form(""),
|
||||||
|
html: str = Form(""),
|
||||||
|
apply_html: str = Form(""),
|
||||||
|
set_display: str = Form(""),
|
||||||
|
set_selling: str = Form(""),
|
||||||
|
memo: str = Form(""),
|
||||||
|
list_query: str = Form(""),
|
||||||
|
):
|
||||||
|
"""편집기에서 예약을 등록한다.
|
||||||
|
|
||||||
|
HTML 을 적용하는 예약이면 지금 편집기 내용을 DRAFT revision 으로 저장해 고정한다.
|
||||||
|
단건 적용과 같은 다듬기(URL 인코딩 → 소스 정리)를 거치므로, 예약이 실행됐을 때
|
||||||
|
저장되는 값이 화면에서 본 것과 같다.
|
||||||
|
"""
|
||||||
|
checked = guard(request)
|
||||||
|
if not isinstance(checked, tuple):
|
||||||
|
return checked
|
||||||
|
st, user = checked
|
||||||
|
|
||||||
|
actor = str(user.get("email") or "")
|
||||||
|
base = f"/cafe24/?{list_query}" if list_query else f"/cafe24/?selected={product_no}"
|
||||||
|
back = base if f"selected={product_no}" in base else f"{base}&selected={product_no}"
|
||||||
|
|
||||||
|
want_html = bool(apply_html)
|
||||||
|
display_flag = store.parse_tristate(set_display)
|
||||||
|
selling_flag = store.parse_tristate(set_selling)
|
||||||
|
|
||||||
|
if not want_html and display_flag is None and selling_flag is None:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"{back}&err=예약할 내용을 하나 이상 선택하세요(상세페이지 / 진열 / 판매).",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_at = store.parse_schedule_at(scheduled_at)
|
||||||
|
except ValueError as exc:
|
||||||
|
return RedirectResponse(url=f"{back}&err={exc}", status_code=303)
|
||||||
|
|
||||||
|
revision_id: int | None = None
|
||||||
|
if want_html:
|
||||||
|
body = store.format_html(store.encode_html_urls(html or ""))
|
||||||
|
if not body.strip():
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"{back}&err=상세페이지 내용이 비어 있습니다.", status_code=303
|
||||||
|
)
|
||||||
|
revision_id = st.add_revision(
|
||||||
|
product_no=product_no,
|
||||||
|
html_content=body,
|
||||||
|
revision_type=store.REVISION_DRAFT,
|
||||||
|
memo=f"예약 등록 ({run_at.strftime('%Y-%m-%d %H:%M')} 적용 예정)",
|
||||||
|
created_by=actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule_id = st.create_schedule(
|
||||||
|
product_no=product_no,
|
||||||
|
scheduled_at=run_at,
|
||||||
|
revision_id=revision_id,
|
||||||
|
set_display=display_flag,
|
||||||
|
set_selling=selling_flag,
|
||||||
|
memo=memo,
|
||||||
|
created_by=actor,
|
||||||
|
)
|
||||||
|
detail = store.describe_schedule_action(
|
||||||
|
has_html=want_html, set_display=display_flag, set_selling=selling_flag
|
||||||
|
)
|
||||||
|
st.log_audit(
|
||||||
|
actor=actor, action="schedule_create", product_no=product_no,
|
||||||
|
revision_id=revision_id, schedule_id=schedule_id, result="SUCCESS",
|
||||||
|
detail=f"{run_at.strftime('%Y-%m-%d %H:%M')} — {detail}",
|
||||||
|
)
|
||||||
|
logger.info("카페24 예약 등록 #%s 상품 %s (%s)", schedule_id, product_no, detail)
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/cafe24/schedules?msg={run_at.strftime('%Y-%m-%d %H:%M')} 예약을 등록했습니다 — {detail}",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@schedules_router.post("/schedules/{schedule_id}/cancel")
|
||||||
|
def schedule_cancel(request: Request, schedule_id: int):
|
||||||
|
"""대기 중인 예약 취소. 이미 실행됐거나 실행 중이면 아무것도 하지 않는다."""
|
||||||
|
checked = guard(request)
|
||||||
|
if not isinstance(checked, tuple):
|
||||||
|
return checked
|
||||||
|
st, user = checked
|
||||||
|
|
||||||
|
ok = st.cancel_schedule(schedule_id, actor=str(user.get("email") or ""))
|
||||||
|
if ok:
|
||||||
|
return RedirectResponse(url=f"/cafe24/schedules?msg=예약 #{schedule_id} 을 취소했습니다.", status_code=303)
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/cafe24/schedules?err=예약 #{schedule_id} 은 이미 처리되었거나 실행 중이라 취소할 수 없습니다.",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@schedules_router.get("/schedules/preview/{product_no}")
|
||||||
|
def schedule_current_flags(request: Request, product_no: int) -> dict:
|
||||||
|
"""예약 폼의 기본값을 위해 현재 진열/판매 상태를 알려준다(JSON, 읽기 전용)."""
|
||||||
|
from .common import require_store # noqa: WPS433
|
||||||
|
|
||||||
|
st, _user = require_store(request)
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
try:
|
||||||
|
raw = products.get_product(api.client, product_no)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
return {"product_no": product_no, "error": str(exc)}
|
||||||
|
normalized = products.normalize_product(raw)
|
||||||
|
return {
|
||||||
|
"product_no": product_no,
|
||||||
|
"display": normalized["display"],
|
||||||
|
"selling": normalized["selling"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""카페24 시스템 화면 — 연결(OAuth) / 연결 상태 / API 로그 / 작업 로그.
|
||||||
|
|
||||||
|
OAuth 흐름
|
||||||
|
1) 관리자가 [카페24 연결] → GET /cafe24/system/oauth/start
|
||||||
|
state 를 만들어 세션에 넣고 카페24 인증 페이지로 302.
|
||||||
|
2) 카페24가 GET /cafe24/oauth/callback?code=&state= 로 되돌려보냄.
|
||||||
|
세션 state 와 대조(CSRF 방어) 후 code → 토큰 교환, 암호화 저장.
|
||||||
|
|
||||||
|
핸들러는 `def`(동기)로 선언한다. 카페24 API·DB 호출이 블로킹이므로 FastAPI 의
|
||||||
|
스레드풀에서 돌게 두는 편이 이벤트 루프를 막지 않는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
from app.integrations.cafe24 import (
|
||||||
|
Cafe24AuthError,
|
||||||
|
Cafe24ConfigError,
|
||||||
|
Cafe24Error,
|
||||||
|
build_authorize_url,
|
||||||
|
build_cafe24_api,
|
||||||
|
exchange_code,
|
||||||
|
load_config,
|
||||||
|
new_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .common import base_ctx, guard, render_config_needed, require_admin
|
||||||
|
|
||||||
|
logger = logging.getLogger("cafe24.system")
|
||||||
|
|
||||||
|
system_router = APIRouter()
|
||||||
|
|
||||||
|
# 세션에 state 를 담는 키
|
||||||
|
_STATE_KEY = "cafe24_oauth_state"
|
||||||
|
|
||||||
|
|
||||||
|
@system_router.get("/system", response_class=HTMLResponse)
|
||||||
|
def system_page(request: Request) -> HTMLResponse:
|
||||||
|
from app.main import render_template # noqa: WPS433
|
||||||
|
|
||||||
|
checked = guard(request)
|
||||||
|
if not isinstance(checked, tuple):
|
||||||
|
return checked
|
||||||
|
st, user = checked
|
||||||
|
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
try:
|
||||||
|
status = api.tokens.status()
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
status = {
|
||||||
|
"connected": False,
|
||||||
|
"mall_id": api.config.mall_id,
|
||||||
|
"missing": api.config.missing,
|
||||||
|
"needs_reauth": True,
|
||||||
|
"reason": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = base_ctx(request, user, active_tab="system")
|
||||||
|
ctx.update(
|
||||||
|
{
|
||||||
|
"page_title": "카페24 — 시스템",
|
||||||
|
"page_subtitle": "연결 상태 · API 로그 · 작업 로그",
|
||||||
|
"status": status,
|
||||||
|
"api_version": api.config.api_version,
|
||||||
|
"scopes": api.config.scope_param,
|
||||||
|
"redirect_uri": api.config.redirect_uri,
|
||||||
|
"api_logs": st.list_api_logs(limit=50),
|
||||||
|
"audit_logs": st.list_audit_logs(limit=50),
|
||||||
|
"flash": request.query_params.get("msg", ""),
|
||||||
|
"flash_error": request.query_params.get("err", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return render_template(request, "cafe24/system.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@system_router.get("/system/oauth/start")
|
||||||
|
def oauth_start(request: Request):
|
||||||
|
"""카페24 인증 시작 (관리자 전용)."""
|
||||||
|
user = require_admin(request)
|
||||||
|
st = getattr(request.app.state, "cafe24_store", None)
|
||||||
|
if st is None:
|
||||||
|
return render_config_needed(request, user)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
try:
|
||||||
|
state = new_state()
|
||||||
|
url = build_authorize_url(config, state=state)
|
||||||
|
except Cafe24ConfigError as exc:
|
||||||
|
return RedirectResponse(url=f"/cafe24/system?err={exc}", status_code=303)
|
||||||
|
|
||||||
|
request.session[_STATE_KEY] = state
|
||||||
|
return RedirectResponse(url=url, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@system_router.get("/oauth/callback")
|
||||||
|
def oauth_callback(request: Request):
|
||||||
|
"""카페24 콜백 — code → 토큰 교환 후 암호화 저장."""
|
||||||
|
user = require_admin(request)
|
||||||
|
st = getattr(request.app.state, "cafe24_store", None)
|
||||||
|
if st is None:
|
||||||
|
return render_config_needed(request, user)
|
||||||
|
|
||||||
|
expected = request.session.pop(_STATE_KEY, "")
|
||||||
|
received = request.query_params.get("state", "")
|
||||||
|
error = request.query_params.get("error", "")
|
||||||
|
code = request.query_params.get("code", "")
|
||||||
|
|
||||||
|
if error:
|
||||||
|
return RedirectResponse(url=f"/cafe24/system?err=카페24 인증이 취소되었습니다. ({error})", status_code=303)
|
||||||
|
if not expected or expected != received:
|
||||||
|
# state 불일치 = 위조된 콜백일 수 있다. 토큰 교환하지 않는다.
|
||||||
|
logger.warning("카페24 OAuth state 불일치 — 콜백 거부")
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/cafe24/system?err=인증 state 가 일치하지 않습니다. 다시 시도하세요.",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
if not code:
|
||||||
|
return RedirectResponse(url="/cafe24/system?err=인증 코드가 없습니다.", status_code=303)
|
||||||
|
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
try:
|
||||||
|
bundle = exchange_code(api.config, code=code)
|
||||||
|
api.tokens.save_bundle(bundle, connected_by=str(user.get("email") or ""))
|
||||||
|
except (Cafe24AuthError, Cafe24ConfigError) as exc:
|
||||||
|
st.log_audit(
|
||||||
|
actor=str(user.get("email") or ""),
|
||||||
|
action="oauth_connect",
|
||||||
|
result="FAIL",
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
return RedirectResponse(url=f"/cafe24/system?err={exc}", status_code=303)
|
||||||
|
|
||||||
|
st.log_audit(
|
||||||
|
actor=str(user.get("email") or ""),
|
||||||
|
action="oauth_connect",
|
||||||
|
result="SUCCESS",
|
||||||
|
detail=f"scopes={bundle.scopes}",
|
||||||
|
)
|
||||||
|
logger.info("카페24 연결 완료 (mall_id=%s)", api.config.mall_id)
|
||||||
|
return RedirectResponse(url="/cafe24/system?msg=카페24에 연결되었습니다.", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@system_router.post("/system/oauth/disconnect")
|
||||||
|
def oauth_disconnect(request: Request):
|
||||||
|
"""저장된 토큰 삭제 (관리자 전용). 이력/예약 데이터는 지우지 않는다."""
|
||||||
|
user = require_admin(request)
|
||||||
|
st = getattr(request.app.state, "cafe24_store", None)
|
||||||
|
if st is None:
|
||||||
|
return render_config_needed(request, user)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
st.disconnect(config.mall_id)
|
||||||
|
st.log_audit(
|
||||||
|
actor=str(user.get("email") or ""),
|
||||||
|
action="oauth_disconnect",
|
||||||
|
result="SUCCESS",
|
||||||
|
)
|
||||||
|
return RedirectResponse(url="/cafe24/system?msg=카페24 연결을 해제했습니다.", status_code=303)
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
"""카페24 모듈 순수 로직 — DB/네트워크 I/O 없음(유닛테스트 대상).
|
||||||
|
|
||||||
|
상수, 상태 전이 규칙, HTML 치환/검증처럼 부수효과 없는 함수만 둔다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from app.timezone import KST
|
||||||
|
|
||||||
|
# ── 상세페이지 버전 종류 (cafe24_product_revisions.revision_type) ──
|
||||||
|
REVISION_SYNC = "SYNC" # 카페24 현재값 스냅샷
|
||||||
|
REVISION_DRAFT = "DRAFT" # 저장만 한 초안
|
||||||
|
REVISION_BACKUP = "BACKUP" # 쓰기 직전 자동 백업 ← 복원 기준
|
||||||
|
REVISION_MANUAL = "MANUAL" # 즉시 적용
|
||||||
|
REVISION_SCHEDULED = "SCHEDULED" # 예약 적용
|
||||||
|
REVISION_ROLLBACK = "ROLLBACK" # 과거 버전 되돌림
|
||||||
|
|
||||||
|
REVISION_TYPES: tuple[str, ...] = (
|
||||||
|
REVISION_SYNC,
|
||||||
|
REVISION_DRAFT,
|
||||||
|
REVISION_BACKUP,
|
||||||
|
REVISION_MANUAL,
|
||||||
|
REVISION_SCHEDULED,
|
||||||
|
REVISION_ROLLBACK,
|
||||||
|
)
|
||||||
|
|
||||||
|
REVISION_LABELS: dict[str, str] = {
|
||||||
|
REVISION_SYNC: "현재값 동기화",
|
||||||
|
REVISION_DRAFT: "초안",
|
||||||
|
REVISION_BACKUP: "적용 직전 자동백업",
|
||||||
|
REVISION_MANUAL: "즉시 적용",
|
||||||
|
REVISION_SCHEDULED: "예약 적용",
|
||||||
|
REVISION_ROLLBACK: "복원",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 예약 상태 (cafe24_product_schedules.status) ──
|
||||||
|
STATUS_PENDING = "PENDING"
|
||||||
|
STATUS_PROCESSING = "PROCESSING"
|
||||||
|
STATUS_SUCCESS = "SUCCESS"
|
||||||
|
STATUS_FAILED = "FAILED"
|
||||||
|
STATUS_CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
SCHEDULE_STATUSES: tuple[str, ...] = (
|
||||||
|
STATUS_PENDING,
|
||||||
|
STATUS_PROCESSING,
|
||||||
|
STATUS_SUCCESS,
|
||||||
|
STATUS_FAILED,
|
||||||
|
STATUS_CANCELLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEDULE_STATUS_LABELS: dict[str, str] = {
|
||||||
|
STATUS_PENDING: "대기",
|
||||||
|
STATUS_PROCESSING: "실행중",
|
||||||
|
STATUS_SUCCESS: "완료",
|
||||||
|
STATUS_FAILED: "실패",
|
||||||
|
STATUS_CANCELLED: "취소",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 사용자가 손댈 수 있는 상태 — PROCESSING/SUCCESS 는 임의 변경 금지
|
||||||
|
EDITABLE_STATUSES: tuple[str, ...] = (STATUS_PENDING,)
|
||||||
|
|
||||||
|
# 예약 실패 시 최대 재시도 횟수
|
||||||
|
MAX_RETRY = 3
|
||||||
|
|
||||||
|
# 종료 후 동작 (cafe24_product_schedules.end_action)
|
||||||
|
END_NONE = ""
|
||||||
|
END_RESTORE = "restore" # 적용 직전 BACKUP 으로 복원
|
||||||
|
END_REVISION = "revision" # 지정한 버전 적용
|
||||||
|
END_ACTIONS: tuple[str, ...] = (END_NONE, END_RESTORE, END_REVISION)
|
||||||
|
|
||||||
|
|
||||||
|
def is_editable(status: str) -> bool:
|
||||||
|
"""예약을 수정/취소할 수 있는 상태인지."""
|
||||||
|
return (status or "").strip().upper() in EDITABLE_STATUSES
|
||||||
|
|
||||||
|
|
||||||
|
def can_retry(retry_count: int) -> bool:
|
||||||
|
"""재시도 여지가 남았는지. 소진되면 FAILED 로 확정한다."""
|
||||||
|
try:
|
||||||
|
return int(retry_count) < MAX_RETRY
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def retry_backoff_seconds(retry_count: int) -> int:
|
||||||
|
"""재시도 간격(초). 1분 → 5분 → 15분. 무한 재시도는 하지 않는다."""
|
||||||
|
table = (60, 300, 900)
|
||||||
|
try:
|
||||||
|
index = max(0, int(retry_count))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
index = 0
|
||||||
|
return table[min(index, len(table) - 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_revision_type(value: str) -> str:
|
||||||
|
text = (value or "").strip().upper()
|
||||||
|
return text if text in REVISION_TYPES else REVISION_DRAFT
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_schedule_status(value: str) -> str:
|
||||||
|
"""DB CHECK 제약에 걸리지 않게 상태값을 정규화한다."""
|
||||||
|
text = (value or "").strip().upper()
|
||||||
|
return text if text in SCHEDULE_STATUSES else STATUS_PENDING
|
||||||
|
|
||||||
|
|
||||||
|
def parse_product_no(value: object) -> int:
|
||||||
|
"""상품번호 정규화. 잘못된 값이면 ValueError."""
|
||||||
|
try:
|
||||||
|
number = int(str(value).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("상품번호는 숫자여야 합니다.") from None
|
||||||
|
if number <= 0:
|
||||||
|
raise ValueError("상품번호는 1 이상이어야 합니다.")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 이미지 URL 의 한글 파일명 표시 (%EC%9A%A9… ↔ 용기…)
|
||||||
|
#
|
||||||
|
# 카페24는 상세페이지 HTML 안 이미지 경로를 퍼센트 인코딩해서 저장한다.
|
||||||
|
# src="/web/product/big/%EC%9A%A9%EA%B8%B0…(%ED%99%A9%ED%86%A0)_12.gif"
|
||||||
|
# 사람이 읽을 수 없으니 화면에서는 한글로 풀어 보여주고, 카페24에 쓸 때는 다시
|
||||||
|
# 원래 형식으로 되돌린다. 두 함수는 서로의 역이며 왕복이 보존돼야 한다
|
||||||
|
# (encode(decode(원본)) == 원본).
|
||||||
|
#
|
||||||
|
# 안전 규칙 두 가지:
|
||||||
|
# 1) 디코딩은 **non-ASCII 바이트(%80~%FF)** 만 한다. %20·%3C·%26 같은 ASCII
|
||||||
|
# 이스케이프를 풀면 HTML 구조나 쿼리스트링이 깨진다.
|
||||||
|
# 2) 인코딩은 **URL 속성값 안의 non-ASCII** 만 한다. 본문 한글 텍스트를
|
||||||
|
# 건드리면 페이지가 깨지므로 대상 범위를 정규식으로 좁힌다.
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# src="..." / href='...' 같은 URL 속성값
|
||||||
|
_URL_ATTR_RE = re.compile(
|
||||||
|
r"""(?P<head>\b(?:src|href|poster|data-src|data-original)\s*=\s*(?P<q>["']))(?P<url>[^"']*)(?P=q)""",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# CSS 의 url(...) — 인라인 <style> 안 배경 이미지
|
||||||
|
_CSS_URL_RE = re.compile(
|
||||||
|
r"""(?P<head>url\(\s*(?P<q>["']?))(?P<url>[^"')]*)(?P<tail>(?P=q)\s*\))""",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# 연속된 %XX 중 첫 바이트가 0x80 이상인 구간(= UTF-8 멀티바이트 문자)
|
||||||
|
_NON_ASCII_PCT_RUN = re.compile(r"(?:%[89A-Fa-f][0-9A-Fa-f])+")
|
||||||
|
# 인코딩 대상에서 제외할 문자 = 모든 ASCII 출력문자.
|
||||||
|
# 결과적으로 non-ASCII 와 공백만 %XX 로 바뀐다. 괄호·밑줄·마침표는 카페24
|
||||||
|
# 원본에서도 인코딩되지 않은 채 쓰이므로 반드시 그대로 남겨야 한다.
|
||||||
|
_ASCII_SAFE = "".join(chr(code) for code in range(0x21, 0x7F))
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_pct_run(match: re.Match[str]) -> str:
|
||||||
|
text = match.group(0)
|
||||||
|
try:
|
||||||
|
raw = bytes(int(text[i + 1 : i + 3], 16) for i in range(0, len(text), 3))
|
||||||
|
return raw.decode("utf-8")
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
# UTF-8 이 아니면(EUC-KR 등) 건드리지 않는다 — 깨뜨리는 것보다 낫다.
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def decode_url_value(value: str) -> str:
|
||||||
|
return _NON_ASCII_PCT_RUN.sub(_decode_pct_run, value or "")
|
||||||
|
|
||||||
|
|
||||||
|
def encode_url_value(value: str) -> str:
|
||||||
|
return quote(value or "", safe=_ASCII_SAFE, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _map_urls(html: str, transform) -> str:
|
||||||
|
def attr(match: re.Match[str]) -> str:
|
||||||
|
return f"{match.group('head')}{transform(match.group('url'))}{match.group('q')}"
|
||||||
|
|
||||||
|
def css(match: re.Match[str]) -> str:
|
||||||
|
return f"{match.group('head')}{transform(match.group('url'))}{match.group('tail')}"
|
||||||
|
|
||||||
|
return _CSS_URL_RE.sub(css, _URL_ATTR_RE.sub(attr, html or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def decode_html_urls(html: str) -> str:
|
||||||
|
"""화면 표시용 — URL 안 %XX(한글 등)를 원래 문자로 되돌린다."""
|
||||||
|
return _map_urls(html, decode_url_value)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_html_urls(html: str) -> str:
|
||||||
|
"""카페24 저장용 — URL 안 non-ASCII 를 퍼센트 인코딩으로 되돌린다."""
|
||||||
|
return _map_urls(html, encode_url_value)
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 소스 정리(포맷) — 태그마다 줄을 나누고 들여쓴다.
|
||||||
|
#
|
||||||
|
# ⚠️ 렌더링을 바꾸지 않는 것이 최우선이다. HTML 에서 공백은 의미가 있어서,
|
||||||
|
# 인라인 요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다(이미지 사이가
|
||||||
|
# 벌어지는 고전적인 사고). 그래서 **블록 요소 경계에서만** 줄을 나눈다.
|
||||||
|
# img·br·span·a 같은 인라인 요소와 텍스트는 원래 줄에 그대로 둔다.
|
||||||
|
# <style>·<script>·<pre>·<textarea> 안은 한 글자도 건드리지 않는다.
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# 앞뒤 공백이 렌더링에 영향을 주지 않는 구조 태그만 넣는다.
|
||||||
|
_BLOCK_TAGS = frozenset(
|
||||||
|
"""html head body div p table thead tbody tfoot tr td th caption colgroup col
|
||||||
|
ul ol li dl dt dd section article header footer nav aside main
|
||||||
|
figure figcaption form fieldset legend h1 h2 h3 h4 h5 h6 hr center blockquote
|
||||||
|
style script iframe noscript""".split()
|
||||||
|
)
|
||||||
|
# 안쪽을 원문 그대로 보존할 태그
|
||||||
|
_RAW_TAGS = frozenset({"style", "script", "pre", "textarea"})
|
||||||
|
# 닫는 태그가 없는 태그
|
||||||
|
_VOID_TAGS = frozenset(
|
||||||
|
"area base br col embed hr img input link meta param source track wbr".split()
|
||||||
|
)
|
||||||
|
# 들여쓰기가 무한히 깊어지지 않게 (닫는 태그를 생략한 HTML 이 흔하다)
|
||||||
|
_MAX_INDENT = 12
|
||||||
|
|
||||||
|
_TOKEN_RE = re.compile(
|
||||||
|
r"(?P<comment><!--.*?-->)"
|
||||||
|
r"|(?P<cdata><!\[CDATA\[.*?\]\]>)"
|
||||||
|
r"|(?P<decl><![^>]*>)"
|
||||||
|
r"|(?P<tag><(?P<slash>/?)\s*(?P<name>[a-zA-Z][\w:.-]*)"
|
||||||
|
r"(?P<attrs>(?:\"[^\"]*\"|'[^']*'|[^>\"'])*)>)",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_html(html: str, *, indent: str = " ") -> str:
|
||||||
|
"""상세페이지 HTML 을 사람이 읽기 좋게 정리한다.
|
||||||
|
|
||||||
|
실패하면 원본을 그대로 돌려준다 — 정리보다 안 깨지는 게 중요하다.
|
||||||
|
같은 값을 두 번 넣어도 결과가 같다(멱등).
|
||||||
|
"""
|
||||||
|
source = html or ""
|
||||||
|
if not source.strip():
|
||||||
|
return source
|
||||||
|
try:
|
||||||
|
return _format_html(source, indent)
|
||||||
|
except Exception: # noqa: BLE001 — 어떤 이유로든 원본을 지키는 쪽을 택한다.
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
def _format_html(source: str, indent: str) -> str:
|
||||||
|
lines: list[str] = []
|
||||||
|
buffer = ""
|
||||||
|
depth = 0
|
||||||
|
|
||||||
|
def pad(level: int) -> str:
|
||||||
|
return indent * min(max(level, 0), _MAX_INDENT)
|
||||||
|
|
||||||
|
def flush() -> None:
|
||||||
|
"""모아둔 인라인/텍스트를 내보낸다.
|
||||||
|
|
||||||
|
원문에 이미 있던 줄바꿈은 **그대로 살린다.** 이미지가 한 줄에 하나씩 적혀
|
||||||
|
있으면 그 모양이 저자의 의도이고, 한 줄로 합치면 오히려 읽기 어려워진다.
|
||||||
|
각 줄마다 현재 깊이로 들여쓴다(줄 앞 공백은 렌더링에 영향이 없다).
|
||||||
|
빈 줄은 연속 한 개까지만 남겨 구획을 유지한다.
|
||||||
|
"""
|
||||||
|
nonlocal buffer
|
||||||
|
# 양 끝 공백을 함께 제거한다. `"\n"` 만 벗기면 끝에 남은 `"\n "` 조각이
|
||||||
|
# 빈 줄로 바뀌어 실행마다 빈 줄이 하나씩 늘어난다(멱등 깨짐).
|
||||||
|
# 블록 태그 경계의 공백은 렌더링에 영향이 없으므로 제거해도 안전하다.
|
||||||
|
text = buffer.strip()
|
||||||
|
buffer = ""
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
for raw_line in text.split("\n"):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line:
|
||||||
|
# 문서 맨 앞이나 빈 줄 뒤에는 빈 줄을 더하지 않는다.
|
||||||
|
if lines and lines[-1] != "":
|
||||||
|
lines.append("")
|
||||||
|
continue
|
||||||
|
lines.append(pad(depth) + line)
|
||||||
|
|
||||||
|
position = 0
|
||||||
|
while True:
|
||||||
|
match = _TOKEN_RE.search(source, position)
|
||||||
|
if match is None:
|
||||||
|
buffer += source[position:]
|
||||||
|
break
|
||||||
|
|
||||||
|
buffer += source[position : match.start()]
|
||||||
|
position = match.end()
|
||||||
|
raw = match.group(0)
|
||||||
|
|
||||||
|
# 주석·DOCTYPE 등은 흐름에 그대로 둔다.
|
||||||
|
# 상세페이지에는 `<!-- 대파_타임랩스 --><img ...>` 처럼 바로 뒤 요소를
|
||||||
|
# 설명하는 주석이 많다. 줄을 강제로 나누면 라벨과 대상이 떨어져 오히려
|
||||||
|
# 읽기 나빠진다. 원문에서 줄이 나뉘어 있었다면 flush 가 그 줄바꿈을 살린다.
|
||||||
|
if match.group("comment") or match.group("cdata") or match.group("decl"):
|
||||||
|
buffer += raw
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = (match.group("name") or "").lower()
|
||||||
|
closing = bool(match.group("slash"))
|
||||||
|
self_closed = (match.group("attrs") or "").rstrip().endswith("/")
|
||||||
|
|
||||||
|
# <style>/<script>/<pre>/<textarea> 안은 원문 유지
|
||||||
|
if name in _RAW_TAGS and not closing:
|
||||||
|
end = re.compile(r"</\s*%s\s*>" % re.escape(name), re.IGNORECASE).search(
|
||||||
|
source, position
|
||||||
|
)
|
||||||
|
inner = source[position : end.start()] if end else source[position:]
|
||||||
|
flush()
|
||||||
|
lines.append(pad(depth) + raw)
|
||||||
|
# 앞뒤 빈 줄은 버린다 — 남기면 매번 실행할 때마다 한 줄씩 늘어난다(멱등 깨짐).
|
||||||
|
body = inner.strip("\n")
|
||||||
|
if body:
|
||||||
|
for line in body.split("\n"):
|
||||||
|
lines.append(line.rstrip())
|
||||||
|
if end:
|
||||||
|
lines.append(pad(depth) + end.group(0))
|
||||||
|
position = end.end()
|
||||||
|
else:
|
||||||
|
position = len(source)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 인라인 태그와 텍스트는 줄을 나누지 않는다 (공백이 생기면 렌더링이 바뀐다)
|
||||||
|
if name not in _BLOCK_TAGS:
|
||||||
|
buffer += raw
|
||||||
|
continue
|
||||||
|
|
||||||
|
if closing:
|
||||||
|
flush()
|
||||||
|
depth -= 1
|
||||||
|
lines.append(pad(depth) + raw)
|
||||||
|
else:
|
||||||
|
flush()
|
||||||
|
lines.append(pad(depth) + raw)
|
||||||
|
if name not in _VOID_TAGS and not self_closed:
|
||||||
|
depth += 1
|
||||||
|
|
||||||
|
flush()
|
||||||
|
return "\n".join(_collapse_short_blocks(lines))
|
||||||
|
|
||||||
|
|
||||||
|
# 짧은 블록을 한 줄로 되돌릴 때 쓰는 패턴
|
||||||
|
_OPEN_TAG_LINE = re.compile(
|
||||||
|
r"^(?P<pad>\s*)<(?P<name>[a-zA-Z][\w:.-]*)(?:\"[^\"]*\"|'[^']*'|[^>\"'])*>$"
|
||||||
|
)
|
||||||
|
_BLOCK_TAG_IN_TEXT = re.compile(
|
||||||
|
r"</?(?:%s)\b" % "|".join(sorted(_BLOCK_TAGS)), re.IGNORECASE
|
||||||
|
)
|
||||||
|
# 한 줄로 합칠 최대 길이
|
||||||
|
_COLLAPSE_WIDTH = 120
|
||||||
|
|
||||||
|
|
||||||
|
def _collapse_short_blocks(lines: list[str]) -> list[str]:
|
||||||
|
"""`<td>\n 1\n</td>` 처럼 내용이 한 줄뿐인 짧은 블록은 한 줄로 되돌린다.
|
||||||
|
|
||||||
|
보기 좋게 하려는 것이며, 합치는 규칙이 결정적이라 멱등성은 유지된다.
|
||||||
|
"""
|
||||||
|
out: list[str] = []
|
||||||
|
index = 0
|
||||||
|
while index < len(lines):
|
||||||
|
opening = _OPEN_TAG_LINE.match(lines[index])
|
||||||
|
if opening and index + 2 < len(lines):
|
||||||
|
name = opening.group("name").lower()
|
||||||
|
middle = lines[index + 1].strip()
|
||||||
|
closing = lines[index + 2].strip()
|
||||||
|
merged = lines[index] + middle + closing
|
||||||
|
if (
|
||||||
|
name not in _VOID_TAGS
|
||||||
|
and name not in _RAW_TAGS
|
||||||
|
and closing.lower() == f"</{name}>"
|
||||||
|
and middle
|
||||||
|
and not _BLOCK_TAG_IN_TEXT.search(middle)
|
||||||
|
and len(merged) <= _COLLAPSE_WIDTH
|
||||||
|
):
|
||||||
|
out.append(merged)
|
||||||
|
index += 3
|
||||||
|
continue
|
||||||
|
out.append(lines[index])
|
||||||
|
index += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 예약 입력 검증
|
||||||
|
#
|
||||||
|
# 되돌리기(자동 복원)는 쓰지 않는다. 예약은 "지정 시각에 이 내용을 적용" 뿐이다.
|
||||||
|
# 세 가지를 각각 선택할 수 있다 — 상세페이지 HTML / 진열 / 판매.
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 화면의 select 값 → 3-상태. 빈 값·미지정이면 "변경하지 않음"(None).
|
||||||
|
_TRISTATE: dict[str, bool] = {
|
||||||
|
"on": True, "off": False,
|
||||||
|
"t": True, "f": False,
|
||||||
|
"true": True, "false": False,
|
||||||
|
"1": True, "0": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tristate(value: object) -> bool | None:
|
||||||
|
"""'on'/'off'/'' → True/False/None. 알 수 없는 값은 "변경하지 않음"으로 본다."""
|
||||||
|
return _TRISTATE.get(str(value or "").strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def parse_schedule_at(value: object, *, now: datetime | None = None) -> datetime:
|
||||||
|
"""`datetime-local` 입력('2026-08-20T14:00') → KST aware datetime.
|
||||||
|
|
||||||
|
타임존 표기가 없으므로 KST 로 해석한다(운영 기준 시간대).
|
||||||
|
과거 시각은 거부한다 — worker 가 즉시 실행해버려 "예약"의 의미가 없어진다.
|
||||||
|
"""
|
||||||
|
text = str(value or "").strip().replace(" ", "T")
|
||||||
|
if not text:
|
||||||
|
raise ValueError("예약 시각을 입력하세요.")
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("예약 시각 형식이 올바르지 않습니다.") from None
|
||||||
|
aware = parsed if parsed.tzinfo else parsed.replace(tzinfo=KST)
|
||||||
|
current = now or datetime.now(KST)
|
||||||
|
# 1분 여유 — 폼을 채우는 동안 시간이 흐른 경우를 걸러내지 않기 위해.
|
||||||
|
if aware < current - timedelta(minutes=1):
|
||||||
|
raise ValueError("예약 시각이 이미 지났습니다. 앞으로의 시각을 지정하세요.")
|
||||||
|
return aware
|
||||||
|
|
||||||
|
|
||||||
|
def describe_schedule_action(
|
||||||
|
*, has_html: bool, set_display: bool | None, set_selling: bool | None
|
||||||
|
) -> str:
|
||||||
|
"""예약 내용을 한 줄로 요약(목록·로그 표시용)."""
|
||||||
|
parts: list[str] = []
|
||||||
|
if has_html:
|
||||||
|
parts.append("상세페이지")
|
||||||
|
if set_display is not None:
|
||||||
|
parts.append("진열" if set_display else "미진열")
|
||||||
|
if set_selling is not None:
|
||||||
|
parts.append("판매" if set_selling else "판매중지")
|
||||||
|
return " · ".join(parts) if parts else "없음"
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(html: str) -> str:
|
||||||
|
"""편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
|
||||||
|
|
||||||
|
편집 중에 다른 사람이 카페24 관리자에서 같은 상품을 바꿨다면, 우리가 쓰는
|
||||||
|
순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다.
|
||||||
|
"""
|
||||||
|
return hashlib.sha256((html or "").encode("utf-8")).hexdigest()[:32]
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
{# 오른쪽 편집기 조각.
|
||||||
|
전체 페이지(products.html)가 include 하고, JS 가 /products/{no}/pane 으로
|
||||||
|
같은 조각만 다시 받아 끼워 넣는다. 그래서 여기에는 <script> 를 두지 않는다
|
||||||
|
(innerHTML 로 삽입된 script 는 실행되지 않는다 — JS 는 products.html 에 있고
|
||||||
|
삽입 후 cf24BindEditor() 로 다시 연결한다). #}
|
||||||
|
|
||||||
|
{% if editor_error %}
|
||||||
|
<div class="cf24-flash cf24-flash-err">
|
||||||
|
카페24 조회에 실패했습니다: {{ editor_error }}<br />
|
||||||
|
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="cf24-editor-head">
|
||||||
|
{# 제목 = 상품명. 연필 버튼을 누르면 입력칸으로 바뀐다(평소에는 읽기 전용 —
|
||||||
|
클릭만으로 실수로 고쳐지지 않게). 저장은 JS 가 POST /products/{no}/name.
|
||||||
|
카페24 조회에 실패했을 때(desc 없음)는 현재 이름을 믿을 수 없어 버튼을 뺀다. #}
|
||||||
|
<div class="cf24-name-box" id="cf24-name" data-product-no="{{ product_no }}">
|
||||||
|
<h3 class="cf24-editor-title" id="cf24-name-view">
|
||||||
|
<span id="cf24-name-text">{{ info.product_name or '상품' }}</span>
|
||||||
|
{% if desc %}
|
||||||
|
<button type="button" class="cf24-name-edit" id="cf24-name-edit"
|
||||||
|
title="상품명 수정 (카페24에 즉시 반영)" aria-label="상품명 수정">✎</button>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
{% if desc %}
|
||||||
|
<div class="cf24-name-form is-hidden" id="cf24-name-form">
|
||||||
|
<input class="cf24-name-input" id="cf24-name-input" type="text" maxlength="250"
|
||||||
|
value="{{ info.product_name }}" aria-label="상품명" />
|
||||||
|
<button class="erp-btn erp-btn-primary" type="button" id="cf24-name-save">저장</button>
|
||||||
|
<button class="erp-btn erp-btn-outline" type="button" id="cf24-name-cancel">취소</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<p class="cf24-editor-sub">
|
||||||
|
상품번호 {{ product_no }}
|
||||||
|
{% if info.product_code %}· <code>{{ info.product_code }}</code>{% endif %}
|
||||||
|
{% if info.price %}· {{ info.price }}{% endif %}
|
||||||
|
{% if info.updated_date %}· 최근 수정 {{ info.updated_date }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{# 배지 클릭 = 진열/판매 토글. 카페24 조회에 실패했을 때(desc 없음)는 현재 상태를
|
||||||
|
믿을 수 없으므로 누를 수 없는 표시로만 둔다. 실제 전환은 products.html 의
|
||||||
|
JS 가 POST /products/{no}/status 로 처리하고 응답값으로 다시 그린다. #}
|
||||||
|
<div class="cf24-editor-badges" id="cf24-status" data-product-no="{{ product_no }}">
|
||||||
|
{% if desc %}
|
||||||
|
<button type="button" class="erp-badge cf24-badge-btn {{ 'cf24-badge-ok' if info.display else 'cf24-badge-off' }}"
|
||||||
|
data-status-field="display" data-status-on="{{ 1 if info.display else 0 }}"
|
||||||
|
title="클릭하면 진열 상태를 바꿉니다 (카페24에 즉시 반영)">{{ '진열' if info.display else '미진열' }}</button>
|
||||||
|
<button type="button" class="erp-badge cf24-badge-btn {{ 'cf24-badge-ok' if info.selling else 'cf24-badge-off' }}"
|
||||||
|
data-status-field="selling" data-status-on="{{ 1 if info.selling else 0 }}"
|
||||||
|
title="클릭하면 판매 상태를 바꿉니다 (카페24에 즉시 반영)">{{ '판매' if info.selling else '중지' }}</button>
|
||||||
|
{% else %}
|
||||||
|
{% if info.display %}<span class="erp-badge cf24-badge-ok">진열</span>
|
||||||
|
{% else %}<span class="erp-badge cf24-badge-off">미진열</span>{% endif %}
|
||||||
|
{% if info.selling %}<span class="erp-badge cf24-badge-ok">판매</span>
|
||||||
|
{% else %}<span class="erp-badge cf24-badge-off">중지</span>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 고객이 보는 상세페이지 주소. readonly input 이라 기존 복사 버튼(data-copy)이
|
||||||
|
그대로 동작한다(값을 box.value 로 읽는다). #}
|
||||||
|
{% if product_url %}
|
||||||
|
<div class="cf24-url-row">
|
||||||
|
<input class="cf24-url" id="cf24-url" type="text" readonly value="{{ product_url }}"
|
||||||
|
onclick="this.select();" aria-label="상품 상세페이지 주소" />
|
||||||
|
<button class="erp-btn erp-btn-outline" type="button" data-copy="cf24-url">주소 복사</button>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="{{ product_url }}"
|
||||||
|
target="_blank" rel="noopener noreferrer">쇼핑몰에서 열기</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if desc %}
|
||||||
|
<form class="cf24-editor-form" method="post"
|
||||||
|
action="/cafe24/products/{{ product_no }}/apply"
|
||||||
|
data-confirm="카페24 쇼핑몰에 바로 반영됩니다. 적용할까요? 직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.">
|
||||||
|
<input type="hidden" name="base_fingerprint" value="{{ fingerprint }}" />
|
||||||
|
<input type="hidden" name="list_query" value="{{ list_query }}" />
|
||||||
|
|
||||||
|
<div class="cf24-editor-bar">
|
||||||
|
<span class="cf24-shortcuts">단축키: 주석토글[Ctrl+/] · 줄 복사[Alt+Shift+↑↓] · 줄 이동[Alt+↑↓] · 줄 삭제[Shift+Del]</span>
|
||||||
|
<span class="cf24-editor-bar-right">
|
||||||
|
<input class="cf24-memo" type="text" name="memo" maxlength="200"
|
||||||
|
placeholder="변경 메모 (버전 이력에 남습니다)" />
|
||||||
|
<button class="erp-btn erp-btn-outline" type="button" data-copy="cf24-html-pc">복사</button>
|
||||||
|
<button class="erp-btn erp-btn-outline" type="button"
|
||||||
|
data-reload="{{ product_no }}"
|
||||||
|
title="카페24에서 현재 소스를 다시 읽어옵니다. 카페24 관리자에서 방금 고쳤다면 이걸 누르세요.">다시 읽기</button>
|
||||||
|
<button class="erp-btn erp-btn-primary" type="submit">카페24에 적용</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 색칠된 <pre> 위에 투명한 <textarea> 를 겹쳐 문법 강조를 만든다.
|
||||||
|
<pre> 가 크기를 정하고 <textarea> 는 inset:0 으로 그 위를 정확히 덮는다.
|
||||||
|
줄바꿈을 하지 않고(wrap=off) 가로로 스크롤하므로 줄 번호가 항상 맞는다.
|
||||||
|
두 요소의 폰트·여백이 다르면 글자가 어긋난다 — CSS 에서 함께 관리한다. #}
|
||||||
|
<div class="cf24-code" id="cf24-code-pc">
|
||||||
|
<div class="cf24-gutter" aria-hidden="true"><div class="cf24-gutter-inner" id="cf24-gutter-pc"></div></div>
|
||||||
|
<div class="cf24-code-body">
|
||||||
|
<pre class="cf24-code-hl" id="cf24-hl-pc" aria-hidden="true"></pre>
|
||||||
|
{# title 툴팁은 달지 않는다 — 편집 중 마우스 옆에 뜨면 소스를 가린다.
|
||||||
|
단축키 안내는 위 바(cf24-shortcuts)에 항상 보인다. #}
|
||||||
|
<textarea id="cf24-html-pc" class="cf24-code-input" name="html" wrap="off"
|
||||||
|
spellcheck="false" autocapitalize="off" autocorrect="off">{{ html_pc }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# 예약 — 적용 폼과 형제로 둔다(폼 중첩은 불가). 위 편집기 내용을 JS 가 hidden 에
|
||||||
|
복사해 함께 보낸다. 등록 시점 내용이 DRAFT 버전으로 고정되므로, 이후 편집기를
|
||||||
|
더 고쳐도 예약된 내용은 바뀌지 않는다. #}
|
||||||
|
<details class="cf24-details">
|
||||||
|
<summary>예약 적용 — 지정한 시각에 자동 반영</summary>
|
||||||
|
<form class="cf24-schedule-form" id="cf24-schedule-form" method="post"
|
||||||
|
action="/cafe24/schedules"
|
||||||
|
data-confirm="지정한 시각에 자동으로 반영됩니다. 예약을 등록할까요?">
|
||||||
|
<input type="hidden" name="product_no" value="{{ product_no }}" />
|
||||||
|
<input type="hidden" name="list_query" value="{{ list_query }}" />
|
||||||
|
<input type="hidden" name="html" id="cf24-schedule-html" />
|
||||||
|
|
||||||
|
{# datetime-local 은 로캘·브라우저마다 표시 폭이 달라 잘리는 사고가 있었다(실제
|
||||||
|
발생). 날짜/시간을 별도 input 으로 나누면 각각 폭이 고정이라 잘릴 일이 없다.
|
||||||
|
제출 직전 JS 가 두 값을 합쳐 hidden scheduled_at 에 넣는다 — 서버(store.
|
||||||
|
parse_schedule_at)는 예전과 같은 'YYYY-MM-DDTHH:MM' 형식을 그대로 받으므로
|
||||||
|
백엔드는 변경하지 않았다. #}
|
||||||
|
<input type="hidden" name="scheduled_at" id="cf24-schedule-at" />
|
||||||
|
<div class="cf24-schedule-grid">
|
||||||
|
<label class="cf24-schedule-datetime">
|
||||||
|
<span>예약 시각</span>
|
||||||
|
{# 네이티브 date/time input 은 브라우저가 표시 형식을 강제한다(CSS로 못 바꿈).
|
||||||
|
그래서 코드 편집기와 같은 방식을 쓴다 — 투명한 네이티브 input 을 우리가
|
||||||
|
그린 형식화된 텍스트 위에 겹친다. 클릭·키보드·달력 팝업은 네이티브 그대로
|
||||||
|
동작하고, 보이는 글자만 "2026년 08월 20일"/"오후 07시 30분" 형식이다.
|
||||||
|
value 는 그대로 YYYY-MM-DD / HH:MM 이라 제출 시 합치는 로직은 그대로다. #}
|
||||||
|
<span class="cf24-schedule-datetime-row">
|
||||||
|
<span class="cf24-dt-field cf24-dt-field-date">
|
||||||
|
<span class="cf24-dt-display" id="cf24-schedule-date-display" aria-hidden="true">연도.월.일</span>
|
||||||
|
<input class="cf24-dt-native" type="date" id="cf24-schedule-date"
|
||||||
|
aria-label="예약 날짜" required />
|
||||||
|
</span>
|
||||||
|
<span class="cf24-dt-field cf24-dt-field-time">
|
||||||
|
<span class="cf24-dt-display" id="cf24-schedule-time-display" aria-hidden="true">시:분</span>
|
||||||
|
<input class="cf24-dt-native" type="time" id="cf24-schedule-time"
|
||||||
|
aria-label="예약 시간" required />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>진열</span>
|
||||||
|
<select class="cf24-schedule-input" name="set_display">
|
||||||
|
<option value="">변경 없음</option>
|
||||||
|
<option value="on">진열</option>
|
||||||
|
<option value="off">미진열</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>판매</span>
|
||||||
|
<select class="cf24-schedule-input" name="set_selling">
|
||||||
|
<option value="">변경 없음</option>
|
||||||
|
<option value="on">판매</option>
|
||||||
|
<option value="off">중지</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="cf24-schedule-wide">
|
||||||
|
<span>메모</span>
|
||||||
|
<input class="cf24-schedule-input" type="text" name="memo" maxlength="200"
|
||||||
|
placeholder="예: 8월 프로모션 시작" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cf24-toolbar" style="margin-top:8px;">
|
||||||
|
<label class="cf24-check-inline" style="margin-left:0;">
|
||||||
|
<input type="checkbox" name="apply_html" value="1" checked />
|
||||||
|
위 편집기 내용을 그 시각에 적용
|
||||||
|
</label>
|
||||||
|
<button class="erp-btn erp-btn-primary" type="submit">예약 등록</button>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/cafe24/schedules">예약 목록</a>
|
||||||
|
</div>
|
||||||
|
<p class="cf24-muted" style="margin:8px 0 0;">
|
||||||
|
진열·판매만 바꾸려면 위 체크를 해제하세요. 셋 중 하나 이상은 선택해야 합니다.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="cf24-details">
|
||||||
|
<summary>버전 이력 {% if revisions %}({{ revisions | length }}건){% endif %}</summary>
|
||||||
|
{% if revisions %}
|
||||||
|
<div class="cf24-scroll">
|
||||||
|
<table class="erp-table cf24-compact">
|
||||||
|
<thead>
|
||||||
|
<tr><th>시각</th><th>유형</th><th>길이</th><th>작업자</th><th>메모</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for rev in revisions %}
|
||||||
|
<tr>
|
||||||
|
<td class="cf24-nowrap">{{ rev.created_at }}</td>
|
||||||
|
<td class="cf24-nowrap"><code>{{ rev.revision_type }}</code></td>
|
||||||
|
<td class="cf24-nowrap">{{ rev.html_length }}자</td>
|
||||||
|
<td class="cf24-nowrap">{{ rev.created_by or '—' }}</td>
|
||||||
|
<td>{{ rev.memo or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="cf24-muted">버전 선택 복원은 Phase 6 에서 붙습니다. 내용은 모두 보관됩니다.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="cf24-muted">아직 이 상품의 변경 이력이 없습니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{# 카페24 모듈 공용 상단 탭. active_tab: products | schedules | system #}
|
||||||
|
<div class="erp-page-actions" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||||
|
<a class="erp-btn {% if active_tab=='products' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
||||||
|
href="/cafe24/">상품관리</a>
|
||||||
|
<a class="erp-btn {% if active_tab=='schedules' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
||||||
|
href="/cafe24/schedules">예약관리</a>
|
||||||
|
<a class="erp-btn {% if active_tab=='system' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
||||||
|
href="/cafe24/system">시스템</a>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,751 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260819c" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include "cafe24/_nav.html" %}
|
||||||
|
{% set qs = (list_query ~ '&') if list_query else '' %}
|
||||||
|
|
||||||
|
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
|
||||||
|
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="cf24-flash cf24-flash-err">
|
||||||
|
카페24 조회에 실패했습니다: {{ error }}<br />
|
||||||
|
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="cf24-split">
|
||||||
|
|
||||||
|
{# ── 왼쪽: 상품 목록 ──────────────────────────────────────── #}
|
||||||
|
<aside class="erp-card cf24-pane cf24-pane-list">
|
||||||
|
<form class="cf24-filters" method="get" action="/cafe24/" id="cf24-filter-form">
|
||||||
|
{# 폼이 제출됐음을 알리는 표식. 없으면 체크 해제를 "첫 방문"과 구분할 수 없다. #}
|
||||||
|
<input type="hidden" name="f" value="1" />
|
||||||
|
{% if selected %}<input type="hidden" name="selected" value="{{ selected }}" />{% endif %}
|
||||||
|
<input class="cf24-search" type="search" name="q" value="{{ keyword }}"
|
||||||
|
placeholder="상품명 검색" />
|
||||||
|
<div class="cf24-checks">
|
||||||
|
<label><input type="checkbox" name="display" value="1"
|
||||||
|
{% if only_display %}checked{% endif %} /> 진열중</label>
|
||||||
|
<label><input type="checkbox" name="selling" value="1"
|
||||||
|
{% if only_selling %}checked{% endif %} /> 판매중</label>
|
||||||
|
</div>
|
||||||
|
<div class="cf24-list-count">
|
||||||
|
{{ shown }}건{% if shown != total %} / 전체 {{ total }}건{% endif %}
|
||||||
|
{% if truncated %}<span class="cf24-warn">(상한 도달)</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="cf24-list-scroll">
|
||||||
|
<table class="erp-table cf24-list-table" id="cf24-list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="cf24-col-no" data-sort-key="no" data-sort-type="num">번호</th>
|
||||||
|
<th class="cf24-col-name" data-sort-key="name" data-sort-type="text">상품명</th>
|
||||||
|
<th class="cf24-col-flag" data-sort-key="display" data-sort-type="num">진열</th>
|
||||||
|
<th class="cf24-col-flag" data-sort-key="selling" data-sort-type="num">판매</th>
|
||||||
|
<th class="cf24-col-date" data-sort-key="updated" data-sort-type="text">수정</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr class="cf24-row {% if selected == r.product_no %}is-active{% endif %}"
|
||||||
|
data-no="{{ r.product_no }}"
|
||||||
|
data-name="{{ r.product_name }}"
|
||||||
|
data-display="{{ 1 if r.display else 0 }}"
|
||||||
|
data-selling="{{ 1 if r.selling else 0 }}"
|
||||||
|
data-updated="{{ r.updated_date }}">
|
||||||
|
<td class="cf24-col-no">{{ r.product_no }}</td>
|
||||||
|
<td class="cf24-col-name" title="{{ r.product_name }}">
|
||||||
|
<a href="/cafe24/?{{ qs }}selected={{ r.product_no }}">{{ r.product_name }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="cf24-col-flag">
|
||||||
|
{% if r.display %}<span class="cf24-dot cf24-dot-on" title="진열중"></span>
|
||||||
|
{% else %}<span class="cf24-dot" title="미진열"></span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="cf24-col-flag">
|
||||||
|
{% if r.selling %}<span class="cf24-dot cf24-dot-on" title="판매중"></span>
|
||||||
|
{% else %}<span class="cf24-dot" title="판매중지"></span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{# 연도는 생략(월-일 시:분). 전체 값은 title 로 확인 #}
|
||||||
|
<td class="cf24-col-date" title="{{ r.updated_date }}">{{ r.updated_date[5:16] }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if not rows and not error %}
|
||||||
|
<p class="cf24-muted" style="padding:12px;">조건에 맞는 상품이 없습니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{# ── 오른쪽: 상세페이지 편집 ──────────────────────────────── #}
|
||||||
|
<section class="erp-card cf24-pane cf24-pane-editor" id="cf24-editor-pane">
|
||||||
|
{% if selected %}
|
||||||
|
{% include "cafe24/_editor.html" %}
|
||||||
|
{% else %}
|
||||||
|
<div class="cf24-empty-pane">
|
||||||
|
<h3>왼쪽에서 상품을 선택하세요.</h3>
|
||||||
|
<p class="cf24-muted">
|
||||||
|
선택한 상품의 상세페이지 HTML 을 여기서 바로 편집하고 카페24에 적용할 수 있습니다.<br />
|
||||||
|
적용 직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var pane = document.getElementById("cf24-editor-pane");
|
||||||
|
var listQuery = {{ list_query | tojson }};
|
||||||
|
var dirty = false;
|
||||||
|
|
||||||
|
/* ── 문법 강조 ──────────────────────────────────────────────
|
||||||
|
색칠된 <pre> 를 투명한 <textarea> 뒤에 겹쳐 놓는 방식. 외부 라이브러리를
|
||||||
|
쓰지 않는다(자체 호스팅 원칙). 태그·속성이름·속성값·주석·기호를 구분한다. */
|
||||||
|
var TOKEN_RE = /(<!--[\s\S]*?-->)|(<![^>]*>)|(<\/?)([a-zA-Z][\w:.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)(>)/g;
|
||||||
|
var ATTR_RE = /([\w:.-]+)(?:(\s*=\s*)("[^"]*"|'[^']*'|[^\s"'>]+))?/g;
|
||||||
|
// 이 길이를 넘으면 강조를 끈다 — 타이핑마다 다시 칠하면 느려진다.
|
||||||
|
var HL_LIMIT = 200000;
|
||||||
|
|
||||||
|
function esc(text) {
|
||||||
|
return text.replace(/[&<>]/g, function (c) {
|
||||||
|
return c === "&" ? "&" : c === "<" ? "<" : ">";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintAttrs(text) {
|
||||||
|
return text.replace(ATTR_RE, function (whole, name, eq, val) {
|
||||||
|
if (!name) return esc(whole);
|
||||||
|
var out = '<span class="cf24-t-attr">' + esc(name) + "</span>";
|
||||||
|
if (eq) out += '<span class="cf24-t-pun">' + esc(eq) + "</span>";
|
||||||
|
if (val) out += '<span class="cf24-t-val">' + esc(val) + "</span>";
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintHtml(src) {
|
||||||
|
var out = "", last = 0, m;
|
||||||
|
TOKEN_RE.lastIndex = 0;
|
||||||
|
while ((m = TOKEN_RE.exec(src)) !== null) {
|
||||||
|
out += esc(src.slice(last, m.index));
|
||||||
|
last = TOKEN_RE.lastIndex;
|
||||||
|
if (m[1]) { out += '<span class="cf24-t-com">' + esc(m[1]) + "</span>"; continue; }
|
||||||
|
if (m[2]) { out += '<span class="cf24-t-doc">' + esc(m[2]) + "</span>"; continue; }
|
||||||
|
out += '<span class="cf24-t-pun">' + esc(m[3]) + "</span>" +
|
||||||
|
'<span class="cf24-t-tag">' + esc(m[4]) + "</span>" +
|
||||||
|
paintAttrs(m[5]) +
|
||||||
|
'<span class="cf24-t-pun">' + esc(m[6]) + "</span>";
|
||||||
|
}
|
||||||
|
return out + esc(src.slice(last));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCodeEditor() {
|
||||||
|
var ta = document.getElementById("cf24-html-pc");
|
||||||
|
var hl = document.getElementById("cf24-hl-pc");
|
||||||
|
var gutter = document.getElementById("cf24-gutter-pc");
|
||||||
|
if (!ta || !hl) return;
|
||||||
|
|
||||||
|
var timer = null;
|
||||||
|
|
||||||
|
function renderGutter(count) {
|
||||||
|
if (!gutter || gutter.dataset.lines === String(count)) return;
|
||||||
|
var out = "";
|
||||||
|
for (var i = 1; i <= count; i++) out += i + "\n";
|
||||||
|
gutter.textContent = out;
|
||||||
|
gutter.dataset.lines = String(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크롤 주체는 textarea 다. 색칠 층과 줄 번호를 같은 양만큼 이동시켜
|
||||||
|
// 글자 위치를 맞춘다(크기를 계산해 맞추는 방식은 긴 줄에서 어긋났다).
|
||||||
|
function sync() {
|
||||||
|
var x = ta.scrollLeft, y = ta.scrollTop;
|
||||||
|
hl.style.transform = "translate(" + -x + "px," + -y + "px)";
|
||||||
|
if (gutter) gutter.style.transform = "translateY(" + -y + "px)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function repaint() {
|
||||||
|
// 마지막 줄이 잘리지 않게 개행을 하나 덧붙인다(<pre> 특성).
|
||||||
|
if (ta.value.length > HL_LIMIT) hl.textContent = ta.value + "\n";
|
||||||
|
else hl.innerHTML = paintHtml(ta.value) + "\n";
|
||||||
|
renderGutter(ta.value.split("\n").length);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
// 짧은 소스는 바로 칠해야 줄 번호가 즉시 따라온다.
|
||||||
|
if (ta.value.length < 50000) { repaint(); return; }
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(repaint, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 편집 단축키 공용 헬퍼 ──
|
||||||
|
lineRange() : 지금 선택(없으면 커서)이 걸친 **줄 전체** 범위.
|
||||||
|
applyEdit() : 그 범위를 새 문자열로 바꾸고 선택을 다시 잡는다. */
|
||||||
|
|
||||||
|
function lineRange() {
|
||||||
|
var value = ta.value;
|
||||||
|
var start = ta.selectionStart, end = ta.selectionEnd;
|
||||||
|
// 선택이 개행에서 끝나면 그 다음 줄은 대상이 아니다(드래그로 줄 끝까지
|
||||||
|
// 끌었을 때 아래 줄까지 딸려오는 것을 막는다).
|
||||||
|
if (end > start && value.charAt(end - 1) === "\n") end -= 1;
|
||||||
|
var from = value.lastIndexOf("\n", start - 1) + 1;
|
||||||
|
var to = value.indexOf("\n", end);
|
||||||
|
if (to === -1) to = value.length;
|
||||||
|
return { value: value, start: start, end: end, from: from, to: to };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEdit(from, to, text, selStart, selEnd) {
|
||||||
|
// execCommand 로 넣어야 브라우저의 실행취소(Ctrl+Z) 이력에 남는다.
|
||||||
|
// 지원하지 않으면 value 를 직접 바꾼다(되돌리기만 안 될 뿐 동작은 같다).
|
||||||
|
var value = ta.value;
|
||||||
|
ta.selectionStart = from;
|
||||||
|
ta.selectionEnd = to;
|
||||||
|
var inserted = false;
|
||||||
|
try {
|
||||||
|
// 빈 문자열은 insertText 가 브라우저에 따라 무시된다 — 삭제는 delete 로.
|
||||||
|
inserted = text === ""
|
||||||
|
? document.execCommand("delete")
|
||||||
|
: document.execCommand("insertText", false, text);
|
||||||
|
} catch (err) { inserted = false; }
|
||||||
|
if (!inserted) ta.value = value.slice(0, from) + text + value.slice(to);
|
||||||
|
ta.selectionStart = selStart;
|
||||||
|
ta.selectionEnd = selEnd;
|
||||||
|
dirty = true;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ctrl+/ (Mac ⌘+/) 로 HTML 주석 토글.
|
||||||
|
- 선택이 있으면 그 선택이 걸친 **줄 전체**가 대상이다(반쯤 걸친 줄이
|
||||||
|
잘려 태그가 깨지지 않게).
|
||||||
|
- 선택이 없으면 커서가 있는 줄 하나.
|
||||||
|
- 대상 안에 주석 기호가 하나라도 있으면 **제거**, 없으면 블록 전체를
|
||||||
|
<!-- --> 로 감싼다. HTML 주석은 중첩이 안 되므로 "이미 주석인 부분을
|
||||||
|
또 감싸기"를 피하는 것이 이 규칙의 이유다. */
|
||||||
|
var COMMENT_MARK = /<!--[ \t]?|[ \t]?-->/g;
|
||||||
|
|
||||||
|
function toggleComment() {
|
||||||
|
var r = lineRange();
|
||||||
|
var block = r.value.slice(r.from, r.to);
|
||||||
|
if (!block.trim()) return; // 빈 줄에서는 아무 것도 하지 않는다
|
||||||
|
|
||||||
|
var caretIn = r.start - r.from; // 블록 안에서의 커서 위치
|
||||||
|
var shift = 0, out;
|
||||||
|
|
||||||
|
COMMENT_MARK.lastIndex = 0;
|
||||||
|
if (COMMENT_MARK.test(block)) {
|
||||||
|
COMMENT_MARK.lastIndex = 0;
|
||||||
|
out = block.replace(COMMENT_MARK, function (mark, offset) {
|
||||||
|
if (offset < caretIn) shift -= mark.length; // 커서 앞이 줄어든 만큼
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
var indent = block.match(/^[ \t]*/)[0];
|
||||||
|
out = indent + "<!-- " + block.slice(indent.length) + " -->";
|
||||||
|
shift = caretIn >= indent.length ? 5 : 0; // "<!-- " 길이
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.start === r.end) {
|
||||||
|
var pos = r.from + Math.min(out.length, Math.max(0, caretIn + shift));
|
||||||
|
applyEdit(r.from, r.to, out, pos, pos);
|
||||||
|
} else {
|
||||||
|
// 바꾼 범위를 계속 선택해 둔다(연달아 다시 누르면 그대로 되돌아온다).
|
||||||
|
applyEdit(r.from, r.to, out, r.from, r.from + out.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alt+↑/↓ — 커서가 있는 줄(선택이 있으면 그 줄들)을 위/아래로 옮긴다.
|
||||||
|
윗줄/아랫줄과 통째로 자리를 바꾸는 방식이라 줄 수는 그대로다. */
|
||||||
|
function moveLines(down) {
|
||||||
|
var r = lineRange();
|
||||||
|
var block = r.value.slice(r.from, r.to);
|
||||||
|
if (!down) {
|
||||||
|
if (r.from === 0) return; // 첫 줄 위로는 못 간다
|
||||||
|
var prevFrom = r.value.lastIndexOf("\n", r.from - 2) + 1;
|
||||||
|
var prev = r.value.slice(prevFrom, r.from - 1);
|
||||||
|
var up = -(prev.length + 1);
|
||||||
|
applyEdit(prevFrom, r.to, block + "\n" + prev, r.start + up, r.end + up);
|
||||||
|
} else {
|
||||||
|
if (r.to >= r.value.length) return; // 마지막 줄 아래로는 못 간다
|
||||||
|
var nextTo = r.value.indexOf("\n", r.to + 1);
|
||||||
|
if (nextTo === -1) nextTo = r.value.length;
|
||||||
|
var next = r.value.slice(r.to + 1, nextTo);
|
||||||
|
var dn = next.length + 1;
|
||||||
|
applyEdit(r.from, nextTo, next + "\n" + block, r.start + dn, r.end + dn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alt+Shift+↑/↓ — 같은 줄을 하나 더 만든다.
|
||||||
|
위로 복사하면 커서는 원래 자리(위쪽 사본)에, 아래로 복사하면 새로 생긴
|
||||||
|
아래쪽 사본으로 옮긴다(VS Code 와 같은 느낌). */
|
||||||
|
function duplicateLines(down) {
|
||||||
|
var r = lineRange();
|
||||||
|
var block = r.value.slice(r.from, r.to);
|
||||||
|
var text = block + "\n" + block;
|
||||||
|
var shift = down ? block.length + 1 : 0;
|
||||||
|
applyEdit(r.from, r.to, text, r.start + shift, r.end + shift);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shift+Delete — 커서가 있는 줄(선택이 있으면 그 줄들)을 통째로 지운다.
|
||||||
|
줄바꿈까지 같이 지워야 빈 줄이 남지 않는다. 마지막 줄이면 대신 앞의
|
||||||
|
줄바꿈을 지운다(문서 끝에 빈 줄이 생기지 않게). */
|
||||||
|
function deleteLines() {
|
||||||
|
var r = lineRange();
|
||||||
|
var from = r.from, to = r.to;
|
||||||
|
if (to < r.value.length) to += 1;
|
||||||
|
else if (from > 0) from -= 1;
|
||||||
|
if (from === to) return; // 빈 문서 — 지울 것이 없다
|
||||||
|
|
||||||
|
var rest = r.value.slice(0, from) + r.value.slice(to);
|
||||||
|
// 커서는 지운 자리로 올라온 줄의 같은 칸에 둔다(칸이 모자라면 줄 끝).
|
||||||
|
var lineFrom = rest.lastIndexOf("\n", from - 1) + 1;
|
||||||
|
var lineTo = rest.indexOf("\n", lineFrom);
|
||||||
|
if (lineTo === -1) lineTo = rest.length;
|
||||||
|
var col = Math.min(Math.max(0, r.start - r.from), lineTo - lineFrom);
|
||||||
|
applyEdit(from, to, "", lineFrom + col, lineFrom + col);
|
||||||
|
}
|
||||||
|
|
||||||
|
ta.addEventListener("scroll", sync);
|
||||||
|
ta.addEventListener("input", function () { dirty = true; refresh(); });
|
||||||
|
ta.addEventListener("keydown", function (e) {
|
||||||
|
// Ctrl+/ · ⌘+/ — 자판에 따라 key 가 "/" 가 아닐 수 있어 code 도 함께 본다.
|
||||||
|
if ((e.ctrlKey || e.metaKey) && !e.altKey &&
|
||||||
|
(e.key === "/" || e.code === "Slash" || e.code === "NumpadDivide")) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleComment();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Alt+↑/↓ 줄 이동, Alt+Shift+↑/↓ 줄 복사.
|
||||||
|
var isUp = e.key === "ArrowUp" || e.code === "ArrowUp";
|
||||||
|
var isDown = e.key === "ArrowDown" || e.code === "ArrowDown";
|
||||||
|
if (e.altKey && !e.ctrlKey && !e.metaKey && (isUp || isDown)) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) duplicateLines(isDown);
|
||||||
|
else moveLines(isDown);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Shift+Delete — 줄 삭제. (윈도우 기본 동작인 "잘라내기"를 대신한다)
|
||||||
|
if (e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey &&
|
||||||
|
(e.key === "Delete" || e.code === "Delete")) {
|
||||||
|
e.preventDefault();
|
||||||
|
deleteLines();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Tab 은 포커스 이동이 아니라 들여쓰기로 쓴다.
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
e.preventDefault();
|
||||||
|
var start = ta.selectionStart, end = ta.selectionEnd;
|
||||||
|
ta.value = ta.value.slice(0, start) + " " + ta.value.slice(end);
|
||||||
|
ta.selectionStart = ta.selectionEnd = start + 2;
|
||||||
|
dirty = true;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 편집기 조각을 새로 끼워 넣은 뒤 다시 연결 ──
|
||||||
|
window.cf24BindEditor = function () {
|
||||||
|
dirty = false;
|
||||||
|
setupCodeEditor();
|
||||||
|
|
||||||
|
pane.querySelectorAll("[data-copy]").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var box = document.getElementById(btn.dataset.copy);
|
||||||
|
if (!box) return;
|
||||||
|
var done = function () {
|
||||||
|
var old = btn.textContent;
|
||||||
|
btn.textContent = "복사됨";
|
||||||
|
setTimeout(function () { btn.textContent = old; }, 1500);
|
||||||
|
};
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(box.value).then(done, function () { box.select(); });
|
||||||
|
} else {
|
||||||
|
box.select();
|
||||||
|
try { document.execCommand("copy"); done(); } catch (e) { /* 직접 복사 */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 「다시 읽기」 — 카페24 관리자에서 방금 고친 경우 현재 소스를 강제로 다시 받는다.
|
||||||
|
var reloadBtn = pane.querySelector("[data-reload]");
|
||||||
|
if (reloadBtn) {
|
||||||
|
reloadBtn.addEventListener("click", function () {
|
||||||
|
confirmLeave().then(function (ok) {
|
||||||
|
if (ok) select(reloadBtn.dataset.reload, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 예약 시각 — 네이티브 date/time input 은 투명하게 두고(opacity:0), 보이는
|
||||||
|
// 글자는 우리가 원하는 형식으로 직접 그린다("2026년 08월 20일" / "오후 07시 30분").
|
||||||
|
// input 의 value 자체는 그대로 YYYY-MM-DD / HH:MM 이라 제출 로직은 안 바뀐다.
|
||||||
|
(function setupScheduleDateTime() {
|
||||||
|
var dateEl = document.getElementById("cf24-schedule-date");
|
||||||
|
var timeEl = document.getElementById("cf24-schedule-time");
|
||||||
|
var dateOut = document.getElementById("cf24-schedule-date-display");
|
||||||
|
var timeOut = document.getElementById("cf24-schedule-time-display");
|
||||||
|
if (!dateEl || !timeEl) return;
|
||||||
|
|
||||||
|
var WEEKDAY = ["일", "월", "화", "수", "목", "금", "토"];
|
||||||
|
|
||||||
|
function pad2(n) { return (n < 10 ? "0" : "") + n; }
|
||||||
|
|
||||||
|
function paintDate() {
|
||||||
|
var v = dateEl.value; // "YYYY-MM-DD"
|
||||||
|
if (!v) { dateOut.textContent = "연도.월.일"; return; }
|
||||||
|
var p = v.split("-");
|
||||||
|
var y = parseInt(p[0], 10), mo = parseInt(p[1], 10), d = parseInt(p[2], 10);
|
||||||
|
// new Date(y, mo-1, d) 는 로컬 시간대로 만들어져 날짜가 밀리지 않는다
|
||||||
|
// (문자열을 그대로 new Date("YYYY-MM-DD") 로 파싱하면 UTC 로 해석돼 하루
|
||||||
|
// 어긋날 수 있다).
|
||||||
|
var weekday = WEEKDAY[new Date(y, mo - 1, d).getDay()];
|
||||||
|
dateOut.textContent = p[0] + "년 " + p[1] + "월 " + p[2] + "일 (" + weekday + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintTime() {
|
||||||
|
var v = timeEl.value; // "HH:MM" (24시간제 고정 — 로캘과 무관)
|
||||||
|
if (!v) { timeOut.textContent = "시:분"; return; }
|
||||||
|
var parts = v.split(":");
|
||||||
|
var h = parseInt(parts[0], 10);
|
||||||
|
var period = h < 12 ? "오전" : "오후";
|
||||||
|
var h12 = h % 12;
|
||||||
|
if (h12 === 0) h12 = 12;
|
||||||
|
timeOut.textContent = period + " " + pad2(h12) + "시 " + parts[1] + "분";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 네이티브 달력/시간 팝업은 브라우저가 자체 아이콘(우측 끝) 클릭에만 반응하고
|
||||||
|
// 칸 전체 클릭에는 반응하지 않는다. showPicker() 를 직접 호출해 칸 어디를
|
||||||
|
// 클릭해도 팝업이 뜨게 한다. 지원하지 않는 브라우저에서는 조용히 무시되고
|
||||||
|
// 포커스만 이동한다(기존 동작 그대로 유지되므로 안전).
|
||||||
|
function openPicker(el) {
|
||||||
|
if (typeof el.showPicker === "function") {
|
||||||
|
try { el.showPicker(); } catch (err) { /* 사용자 제스처 밖 호출 등 — 무시 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dateEl.addEventListener("click", function () { openPicker(dateEl); });
|
||||||
|
timeEl.addEventListener("click", function () { openPicker(timeEl); });
|
||||||
|
|
||||||
|
dateEl.addEventListener("input", paintDate);
|
||||||
|
dateEl.addEventListener("change", paintDate);
|
||||||
|
timeEl.addEventListener("input", paintTime);
|
||||||
|
timeEl.addEventListener("change", paintTime);
|
||||||
|
paintDate();
|
||||||
|
paintTime();
|
||||||
|
})();
|
||||||
|
|
||||||
|
// 예약 폼 — 편집기 내용을 hidden 에 복사해 함께 보낸다(폼이 서로 형제라서).
|
||||||
|
// 날짜·시간도 두 개의 별도 input 값을 합쳐 서버가 기대하는 형식으로 만든다.
|
||||||
|
var schedForm = pane.querySelector("#cf24-schedule-form");
|
||||||
|
if (schedForm) {
|
||||||
|
schedForm.addEventListener("submit", function (e) {
|
||||||
|
var box = document.getElementById("cf24-html-pc");
|
||||||
|
var holder = document.getElementById("cf24-schedule-html");
|
||||||
|
var wantHtml = schedForm.querySelector('[name="apply_html"]').checked;
|
||||||
|
if (holder && box) holder.value = wantHtml ? box.value : "";
|
||||||
|
|
||||||
|
var dateEl = document.getElementById("cf24-schedule-date");
|
||||||
|
var timeEl = document.getElementById("cf24-schedule-time");
|
||||||
|
var atHolder = document.getElementById("cf24-schedule-at");
|
||||||
|
// date/time 이 필수(required)라 브라우저가 빈 값이면 여기까지 오지 않는다.
|
||||||
|
if (atHolder && dateEl && timeEl) atHolder.value = dateEl.value + "T" + timeEl.value;
|
||||||
|
|
||||||
|
// 확인창이 비동기라 일단 제출을 멈추고, 확인을 받으면 다시 보낸다.
|
||||||
|
// (required 검사는 이미 통과한 뒤라 form.submit() 으로 보내도 안전하다)
|
||||||
|
e.preventDefault();
|
||||||
|
window.erpConfirm(schedForm.dataset.confirm).then(function (ok) {
|
||||||
|
if (!ok) return;
|
||||||
|
dirty = false; // 예약 등록으로 화면을 떠나므로 편집 중 경고를 끈다.
|
||||||
|
schedForm.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 상품명 수정 ──
|
||||||
|
// 연필 → 입력칸, 저장 시 POST /products/{no}/name. 서버가 쓰기 전에 카페24
|
||||||
|
// 현재값을 읽어 이전 이름을 감사로그에 남긴다.
|
||||||
|
(function setupRename() {
|
||||||
|
var box = pane.querySelector("#cf24-name");
|
||||||
|
var form = box && box.querySelector("#cf24-name-form");
|
||||||
|
if (!box || !form) return; // 조회 실패 시엔 수정 버튼 자체가 없다
|
||||||
|
var no = box.dataset.productNo;
|
||||||
|
var view = box.querySelector("#cf24-name-view");
|
||||||
|
var text = box.querySelector("#cf24-name-text");
|
||||||
|
var input = box.querySelector("#cf24-name-input");
|
||||||
|
var saveBtn = box.querySelector("#cf24-name-save");
|
||||||
|
var cancelBtn = box.querySelector("#cf24-name-cancel");
|
||||||
|
|
||||||
|
function open(on) {
|
||||||
|
view.classList.toggle("is-hidden", on);
|
||||||
|
form.classList.toggle("is-hidden", !on);
|
||||||
|
if (on) { input.value = text.textContent; input.focus(); input.select(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 왼쪽 목록도 다시 받지 않으므로 같은 상품 행의 이름·정렬키를 직접 맞춘다.
|
||||||
|
function paintRow(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function busy(state) {
|
||||||
|
saveBtn.disabled = state;
|
||||||
|
cancelBtn.disabled = state;
|
||||||
|
input.disabled = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
var name = input.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
window.erpAlert("상품명을 입력하세요.").then(function () { input.focus(); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name === text.textContent) { open(false); return; }
|
||||||
|
window.erpConfirm("상품명을 「" + name + "」(으)로 바꿉니다.\n카페24 쇼핑몰에 바로 반영됩니다. 계속할까요?")
|
||||||
|
.then(function (ok) { if (ok) send(name); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(name) {
|
||||||
|
busy(true);
|
||||||
|
fetch("/cafe24/products/" + no + "/name", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: name })
|
||||||
|
})
|
||||||
|
.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;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
// 화면은 요청값이 아니라 카페24가 확인해 준 이름으로 그린다.
|
||||||
|
var applied = data.product_name || name;
|
||||||
|
text.textContent = applied;
|
||||||
|
input.value = applied;
|
||||||
|
paintRow(applied);
|
||||||
|
open(false);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
window.erpAlert("상품명 변경에 실패했습니다: " + err.message);
|
||||||
|
})
|
||||||
|
.then(function () { busy(false); });
|
||||||
|
}
|
||||||
|
|
||||||
|
box.querySelector("#cf24-name-edit").addEventListener("click", function () { open(true); });
|
||||||
|
cancelBtn.addEventListener("click", function () { open(false); });
|
||||||
|
saveBtn.addEventListener("click", save);
|
||||||
|
input.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); save(); }
|
||||||
|
else if (e.key === "Escape") { e.preventDefault(); open(false); }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── 진열/판매 배지 클릭 = 상태 토글 ──
|
||||||
|
// 쓰기는 서버가 한다(POST /products/{no}/status). 화면은 **응답에 담긴 실제
|
||||||
|
// 상태**로 다시 그린다 — 요청값을 낙관적으로 반영하면 실패했을 때 화면과
|
||||||
|
// 카페24가 어긋난다.
|
||||||
|
(function setupStatusToggle() {
|
||||||
|
var box = pane.querySelector("#cf24-status");
|
||||||
|
if (!box) return;
|
||||||
|
var no = box.dataset.productNo;
|
||||||
|
var LABEL = {
|
||||||
|
display: { word: "진열", on: "진열", off: "미진열", dotOn: "진열중", dotOff: "미진열" },
|
||||||
|
selling: { word: "판매", on: "판매", off: "중지", dotOn: "판매중", dotOff: "판매중지" }
|
||||||
|
};
|
||||||
|
var buttons = box.querySelectorAll("[data-status-field]");
|
||||||
|
|
||||||
|
function paint(btn, on) {
|
||||||
|
var label = LABEL[btn.dataset.statusField];
|
||||||
|
btn.dataset.statusOn = on ? "1" : "0";
|
||||||
|
btn.textContent = on ? label.on : label.off;
|
||||||
|
btn.classList.toggle("cf24-badge-ok", on);
|
||||||
|
btn.classList.toggle("cf24-badge-off", !on);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 왼쪽 목록은 다시 불러오지 않으므로 같은 상품 행의 점도 직접 맞춘다.
|
||||||
|
// data-display/data-selling 은 정렬 기준이라 함께 갱신한다.
|
||||||
|
function paintRow(state) {
|
||||||
|
var tr = document.querySelector('#cf24-list tr.cf24-row[data-no="' + no + '"]');
|
||||||
|
if (!tr) return;
|
||||||
|
["display", "selling"].forEach(function (field, i) {
|
||||||
|
var on = !!state[field];
|
||||||
|
var label = LABEL[field];
|
||||||
|
tr.dataset[field] = on ? "1" : "0";
|
||||||
|
var dot = tr.querySelectorAll(".cf24-dot")[i];
|
||||||
|
if (!dot) return;
|
||||||
|
dot.classList.toggle("cf24-dot-on", on);
|
||||||
|
dot.title = on ? label.dotOn : label.dotOff;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function busy(state) {
|
||||||
|
buttons.forEach(function (b) { b.disabled = state; });
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons.forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var field = btn.dataset.statusField;
|
||||||
|
var label = LABEL[field];
|
||||||
|
var want = btn.dataset.statusOn !== "1";
|
||||||
|
var msg = label.word + " 상태를 「" + (want ? label.on : label.off) + "」(으)로 바꿉니다.\n" +
|
||||||
|
"카페24 쇼핑몰에 바로 반영됩니다. 계속할까요?";
|
||||||
|
window.erpConfirm(msg).then(function (ok) { if (ok) send(field, want); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function send(field, want) {
|
||||||
|
busy(true);
|
||||||
|
fetch("/cafe24/products/" + no + "/status", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ field: field, value: want })
|
||||||
|
})
|
||||||
|
.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;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
buttons.forEach(function (b) { paint(b, !!data[b.dataset.statusField]); });
|
||||||
|
paintRow(data);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
window.erpAlert("상태 변경에 실패했습니다: " + err.message);
|
||||||
|
})
|
||||||
|
.then(function () { busy(false); });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
var form = pane.querySelector(".cf24-editor-form");
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.erpConfirm(form.dataset.confirm).then(function (ok) {
|
||||||
|
if (!ok) return;
|
||||||
|
dirty = false;
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공용 확인창(erp-dialog.js)은 비동기다 — 기본 confirm() 과 달리 Promise 를
|
||||||
|
// 돌려주므로 호출부는 모두 then() 안에서 이어간다.
|
||||||
|
function confirmLeave() {
|
||||||
|
if (!dirty) return Promise.resolve(true);
|
||||||
|
return window.erpConfirm("편집한 내용이 저장되지 않았습니다. 이동할까요?");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 목록 클릭 → 오른쪽만 교체 ──
|
||||||
|
function select(no, push) {
|
||||||
|
pane.innerHTML = '<p class="cf24-muted" style="padding:16px;">불러오는 중…</p>';
|
||||||
|
var url = "/cafe24/products/" + no + "/pane" + (listQuery ? "?" + listQuery : "");
|
||||||
|
// no-store: 캐시된 조각이 뜨면 카페24의 현재 소스가 아닌 예전 것을 편집하게 된다.
|
||||||
|
fetch(url, { credentials: "same-origin", cache: "no-store" })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.redirected) { window.location.href = res.url; return null; }
|
||||||
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||||
|
return res.text();
|
||||||
|
})
|
||||||
|
.then(function (htmlText) {
|
||||||
|
if (htmlText === null) return;
|
||||||
|
pane.innerHTML = htmlText;
|
||||||
|
window.cf24BindEditor();
|
||||||
|
pane.scrollTop = 0;
|
||||||
|
if (push) {
|
||||||
|
var target = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no;
|
||||||
|
history.pushState({ no: no }, "", target);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// 조각 로드가 실패하면 평범한 페이지 이동으로 대체한다.
|
||||||
|
window.location.href = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("#cf24-list tbody tr.cf24-row").forEach(function (tr) {
|
||||||
|
tr.addEventListener("click", function (e) {
|
||||||
|
if (e.target.tagName === "A") e.preventDefault();
|
||||||
|
confirmLeave().then(function (ok) {
|
||||||
|
if (!ok) return;
|
||||||
|
document.querySelectorAll("#cf24-list tr.is-active").forEach(function (el) {
|
||||||
|
el.classList.remove("is-active");
|
||||||
|
});
|
||||||
|
tr.classList.add("is-active");
|
||||||
|
select(tr.dataset.no, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("popstate", function () {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 필터 체크박스는 즉시 적용 ──
|
||||||
|
var filterForm = document.getElementById("cf24-filter-form");
|
||||||
|
filterForm.querySelectorAll('input[type="checkbox"]').forEach(function (cb) {
|
||||||
|
cb.addEventListener("change", function () {
|
||||||
|
confirmLeave().then(function (ok) {
|
||||||
|
// 확인창이 비동기라 체크는 이미 바뀌어 있다 — 취소하면 되돌린다.
|
||||||
|
if (ok) filterForm.submit();
|
||||||
|
else cb.checked = !cb.checked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 제목행 클릭 정렬(오름/내림 토글) ──
|
||||||
|
var table = document.getElementById("cf24-list");
|
||||||
|
var tbody = table.querySelector("tbody");
|
||||||
|
var sortKey = null, sortAsc = true;
|
||||||
|
|
||||||
|
table.querySelectorAll("th[data-sort-key]").forEach(function (th) {
|
||||||
|
th.classList.add("cf24-sortable");
|
||||||
|
th.addEventListener("click", function () {
|
||||||
|
var key = th.dataset.sortKey;
|
||||||
|
sortAsc = key === sortKey ? !sortAsc : true;
|
||||||
|
sortKey = key;
|
||||||
|
|
||||||
|
table.querySelectorAll("th[data-sort-key]").forEach(function (other) {
|
||||||
|
other.classList.remove("is-asc", "is-desc");
|
||||||
|
});
|
||||||
|
th.classList.add(sortAsc ? "is-asc" : "is-desc");
|
||||||
|
|
||||||
|
var numeric = th.dataset.sortType === "num";
|
||||||
|
var rows = Array.prototype.slice.call(tbody.querySelectorAll("tr.cf24-row"));
|
||||||
|
rows.sort(function (a, b) {
|
||||||
|
var x = a.dataset[key] || "", y = b.dataset[key] || "";
|
||||||
|
var cmp = numeric
|
||||||
|
? (parseFloat(x) || 0) - (parseFloat(y) || 0)
|
||||||
|
: x.localeCompare(y, "ko");
|
||||||
|
return sortAsc ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
rows.forEach(function (tr) { tbody.appendChild(tr); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", function (e) {
|
||||||
|
if (dirty) { e.preventDefault(); e.returnValue = ""; }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pane.querySelector(".cf24-editor-form")) window.cf24BindEditor();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260819c" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include "cafe24/_nav.html" %}
|
||||||
|
|
||||||
|
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
|
||||||
|
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
|
||||||
|
|
||||||
|
<div class="erp-card cf24-card">
|
||||||
|
<div class="cf24-card-head">
|
||||||
|
<h3>예약 목록</h3>
|
||||||
|
<span class="cf24-muted">대기 {{ pending }}건 · 최근 200건</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="cf24-note">
|
||||||
|
예약은 <strong>상품관리 화면의 편집기 아래 「예약 적용」</strong> 에서 등록합니다.
|
||||||
|
지정한 시각에 <code>dbx-cafe24-worker</code> 가 적용하므로 브라우저를 닫아도 실행됩니다.
|
||||||
|
적용 직전 내용은 상품별 <code>BACKUP</code> 버전으로 보관됩니다.
|
||||||
|
<strong>대기</strong> 상태인 예약만 취소할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if rows %}
|
||||||
|
<div class="cf24-scroll">
|
||||||
|
<table class="erp-table cf24-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:56px;">번호</th>
|
||||||
|
<th style="width:130px;">예정 시각</th>
|
||||||
|
<th style="width:66px;">상품</th>
|
||||||
|
<th>상품명</th>
|
||||||
|
<th style="width:150px;">적용 내용</th>
|
||||||
|
<th style="width:96px;">상태</th>
|
||||||
|
<th>메모 / 오류</th>
|
||||||
|
<th style="width:130px;">등록자</th>
|
||||||
|
<th style="width:60px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="cf24-nowrap">#{{ r.id }}</td>
|
||||||
|
<td class="cf24-nowrap">{{ (r.scheduled_at or '')[:16] | replace("T", " ") }}</td>
|
||||||
|
<td class="cf24-nowrap">
|
||||||
|
<a href="/cafe24/?selected={{ r.product_no }}">{{ r.product_no }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.product_name or '—' }}</td>
|
||||||
|
<td class="cf24-nowrap">{{ r.action_label }}</td>
|
||||||
|
<td class="cf24-nowrap">
|
||||||
|
{% if r.status == 'SUCCESS' %}<span class="erp-badge cf24-badge-ok">{{ r.status_label }}</span>
|
||||||
|
{% elif r.status == 'FAILED' %}<span class="cf24-err">{{ r.status_label }}</span>
|
||||||
|
{% elif r.status == 'PENDING' %}<span class="cf24-warn">{{ r.status_label }}</span>
|
||||||
|
{% else %}<span class="cf24-muted">{{ r.status_label }}</span>{% endif %}
|
||||||
|
{% if r.retry_count %}<span class="cf24-muted">({{ r.retry_count }}회)</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ r.memo or '' }}
|
||||||
|
{% if r.last_error %}<div class="cf24-err">{{ r.last_error }}</div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="cf24-nowrap">{{ r.created_by or '—' }}</td>
|
||||||
|
<td class="cf24-nowrap">
|
||||||
|
{% if r.editable %}
|
||||||
|
<form method="post" action="/cafe24/schedules/{{ r.id }}/cancel" style="display:inline;"
|
||||||
|
data-erp-confirm="예약 #{{ r.id }} 을 취소할까요?">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline">취소</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="cf24-muted">등록된 예약이 없습니다. 상품관리 화면에서 상품을 고른 뒤 편집기 아래에서 예약할 수 있습니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260819c" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include "cafe24/_nav.html" %}
|
||||||
|
|
||||||
|
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
|
||||||
|
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
|
||||||
|
|
||||||
|
{# ── 연결 상태 ───────────────────────────────────────────── #}
|
||||||
|
<div class="erp-card cf24-card">
|
||||||
|
<div class="cf24-card-head">
|
||||||
|
<h3>카페24 연결</h3>
|
||||||
|
{% if status.connected %}
|
||||||
|
<span class="erp-badge cf24-badge-ok">연결됨</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="erp-badge cf24-badge-off">연결 안 됨</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if status.missing %}
|
||||||
|
<div class="cf24-flash cf24-flash-err">
|
||||||
|
다음 환경변수가 설정되지 않았습니다:
|
||||||
|
<code>{{ status.missing | join(', ') }}</code><br />
|
||||||
|
<code>.env</code> 에 추가한 뒤 컨테이너를 재기동하세요.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if status.reason %}<p class="cf24-muted">{{ status.reason }}</p>{% endif %}
|
||||||
|
|
||||||
|
<table class="erp-table cf24-kv">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>쇼핑몰 ID</th><td>{{ status.mall_id or '—' }}</td></tr>
|
||||||
|
<tr><th>API 버전</th><td>{{ api_version }}</td></tr>
|
||||||
|
<tr><th>요청 권한(scope)</th><td><code>{{ scopes }}</code></td></tr>
|
||||||
|
<tr><th>Redirect URI</th><td><code>{{ redirect_uri or '—' }}</code></td></tr>
|
||||||
|
<tr>
|
||||||
|
<th>승인된 권한</th>
|
||||||
|
<td>{% if status.scopes %}<code>{{ status.scopes }}</code>{% else %}—{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Access Token 만료</th>
|
||||||
|
<td>
|
||||||
|
{{ status.access_token_expires_at or '—' }}
|
||||||
|
{% if status.access_expired %}<span class="cf24-muted">(만료 — 다음 호출 시 자동 갱신)</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><th>Refresh Token 만료</th><td>{{ status.refresh_token_expires_at or '—' }}</td></tr>
|
||||||
|
<tr><th>마지막 갱신</th><td>{{ status.last_refreshed_at or '—' }}</td></tr>
|
||||||
|
<tr><th>연결한 사람</th><td>{{ status.connected_by or '—' }}</td></tr>
|
||||||
|
{% if status.last_error %}
|
||||||
|
<tr><th>마지막 오류</th><td class="cf24-err">{{ status.last_error }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
<div class="cf24-actions">
|
||||||
|
<a class="erp-btn erp-btn-primary" href="/cafe24/system/oauth/start">
|
||||||
|
{% if status.connected %}카페24 재연결{% else %}카페24 연결{% endif %}
|
||||||
|
</a>
|
||||||
|
{% if status.connected or status.needs_reauth %}
|
||||||
|
<form method="post" action="/cafe24/system/oauth/disconnect" style="display:inline;"
|
||||||
|
data-erp-confirm="저장된 카페24 토큰을 삭제합니다. 계속할까요? (변경 이력·예약 데이터는 지워지지 않습니다)">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline">연결 해제</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="cf24-muted">카페24 연결 변경은 관리자만 할 수 있습니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── 작업 로그 ───────────────────────────────────────────── #}
|
||||||
|
<div class="erp-card cf24-card">
|
||||||
|
<div class="cf24-card-head"><h3>작업 로그</h3><span class="cf24-muted">최근 50건</span></div>
|
||||||
|
{% if audit_logs %}
|
||||||
|
<div class="cf24-scroll">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>시각</th><th>작업자</th><th>작업</th><th>상품</th><th>결과</th><th>내용</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in audit_logs %}
|
||||||
|
<tr>
|
||||||
|
<td class="cf24-nowrap">{{ log.created_at }}</td>
|
||||||
|
<td>{{ log.actor or '—' }}</td>
|
||||||
|
<td>{{ log.action }}</td>
|
||||||
|
<td>{{ log.product_no or '—' }}</td>
|
||||||
|
<td class="{% if log.result == 'FAIL' %}cf24-err{% endif %}">{{ log.result or '—' }}</td>
|
||||||
|
<td>{{ log.detail or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="cf24-muted">아직 기록된 작업이 없습니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── API 로그 ────────────────────────────────────────────── #}
|
||||||
|
<div class="erp-card cf24-card">
|
||||||
|
<div class="cf24-card-head">
|
||||||
|
<h3>카페24 API 로그</h3>
|
||||||
|
<span class="cf24-muted">최근 50건 · 토큰/시크릿은 기록하지 않습니다</span>
|
||||||
|
</div>
|
||||||
|
{% if api_logs %}
|
||||||
|
<div class="cf24-scroll">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>시각</th><th>메서드</th><th>엔드포인트</th><th>상품</th><th>상태</th><th>결과</th><th>소요</th><th>오류</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in api_logs %}
|
||||||
|
<tr>
|
||||||
|
<td class="cf24-nowrap">{{ log.created_at }}</td>
|
||||||
|
<td>{{ log.method }}</td>
|
||||||
|
<td><code>{{ log.endpoint }}</code></td>
|
||||||
|
<td>{{ log.product_no or '—' }}</td>
|
||||||
|
<td>{{ log.http_status or '—' }}</td>
|
||||||
|
<td class="{% if log.result != 'SUCCESS' %}cf24-err{% endif %}">{{ log.result }}</td>
|
||||||
|
<td class="cf24-nowrap">{{ log.duration_ms }}ms</td>
|
||||||
|
<td>{{ log.error_message or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="cf24-muted">아직 API 호출 기록이 없습니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,790 @@
|
|||||||
|
"""카페24 모듈 순수 로직 + 토큰/암호화 테스트.
|
||||||
|
|
||||||
|
DB/네트워크 없이 검증한다(가짜 저장소 + refresh 함수 주입).
|
||||||
|
python -m app.modules.cafe24.tests.test_cafe24
|
||||||
|
또는 pytest 로 실행 가능.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from app.integrations.cafe24 import config as cfgmod
|
||||||
|
from app.integrations.cafe24 import crypto, oauth, products, tokens
|
||||||
|
from app.integrations.cafe24.errors import Cafe24ApiError, Cafe24AuthError, Cafe24ConfigError
|
||||||
|
from app.modules.cafe24 import store, worker
|
||||||
|
from app.timezone import now_kst
|
||||||
|
|
||||||
|
SECRET = "unit-test-secret"
|
||||||
|
|
||||||
|
_TEST_ENV = {
|
||||||
|
"CAFE24_MALL_ID": "testmall",
|
||||||
|
"CAFE24_CLIENT_ID": "cid",
|
||||||
|
"CAFE24_CLIENT_SECRET": "csecret",
|
||||||
|
"CAFE24_REDIRECT_URI": "http://localhost:8080/cafe24/oauth/callback",
|
||||||
|
"CAFE24_TOKEN_SECRET": SECRET,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _config():
|
||||||
|
"""환경변수에 의존하지 않도록 테스트용 값을 주입해 설정을 만든다."""
|
||||||
|
saved = {k: os.environ.get(k) for k in _TEST_ENV}
|
||||||
|
os.environ.update(_TEST_ENV)
|
||||||
|
try:
|
||||||
|
return cfgmod.load_config()
|
||||||
|
finally:
|
||||||
|
for key, value in saved.items():
|
||||||
|
if value is None:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
else:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRepo:
|
||||||
|
"""Cafe24Store 의 토큰 3개 메서드만 흉내낸다."""
|
||||||
|
|
||||||
|
def __init__(self, row=None):
|
||||||
|
self.row = row
|
||||||
|
self.saves: list[dict] = []
|
||||||
|
|
||||||
|
def get_token_row(self, mall_id):
|
||||||
|
return self.row
|
||||||
|
|
||||||
|
def save_token_row(self, *, mall_id, **fields):
|
||||||
|
self.saves.append(fields)
|
||||||
|
if self.row is None:
|
||||||
|
self.row = {"mall_id": mall_id}
|
||||||
|
self.row.update(fields)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def token_lock(self, mall_id):
|
||||||
|
outer = self
|
||||||
|
|
||||||
|
class Handle:
|
||||||
|
row = outer.row
|
||||||
|
|
||||||
|
def save(self, **fields):
|
||||||
|
outer.save_token_row(mall_id=mall_id, **fields)
|
||||||
|
|
||||||
|
yield Handle()
|
||||||
|
|
||||||
|
|
||||||
|
def _row(**overrides):
|
||||||
|
row = {
|
||||||
|
"mall_id": "testmall",
|
||||||
|
"access_token": crypto.encrypt("AT", secret=SECRET),
|
||||||
|
"refresh_token": crypto.encrypt("RT", secret=SECRET),
|
||||||
|
"access_token_expires_at": now_kst() + timedelta(hours=1),
|
||||||
|
"refresh_token_expires_at": now_kst() + timedelta(days=13),
|
||||||
|
"scopes": "mall.read_product,mall.write_product",
|
||||||
|
"last_refreshed_at": now_kst(),
|
||||||
|
"last_error": "",
|
||||||
|
"connected_by": "king@dbxcorp.co.kr",
|
||||||
|
}
|
||||||
|
row.update(overrides)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 암호화
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def test_crypto_roundtrip():
|
||||||
|
token = "ACCESS-TOKEN-한글-123"
|
||||||
|
encrypted = crypto.encrypt(token, secret=SECRET)
|
||||||
|
assert encrypted != token and token not in encrypted
|
||||||
|
assert crypto.decrypt(encrypted, secret=SECRET) == token
|
||||||
|
|
||||||
|
|
||||||
|
def test_crypto_empty_passthrough():
|
||||||
|
assert crypto.encrypt("", secret=SECRET) == ""
|
||||||
|
assert crypto.decrypt("", secret=SECRET) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_crypto_wrong_secret_raises():
|
||||||
|
encrypted = crypto.encrypt("AT", secret=SECRET)
|
||||||
|
try:
|
||||||
|
crypto.decrypt(encrypted, secret="다른키")
|
||||||
|
except Cafe24ConfigError:
|
||||||
|
return
|
||||||
|
raise AssertionError("키가 바뀌면 Cafe24ConfigError 가 나야 한다")
|
||||||
|
|
||||||
|
|
||||||
|
def test_crypto_requires_secret():
|
||||||
|
try:
|
||||||
|
crypto.encrypt("x", secret="")
|
||||||
|
except Cafe24ConfigError:
|
||||||
|
return
|
||||||
|
raise AssertionError("CAFE24_TOKEN_SECRET 없으면 예외여야 한다")
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 설정 / 인증 URL
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def test_config_basics():
|
||||||
|
config = _config()
|
||||||
|
assert config.configured
|
||||||
|
assert config.missing == []
|
||||||
|
assert config.api_base == "https://testmall.cafe24api.com/api/v2"
|
||||||
|
# 요청 scope 는 DEFAULT_SCOPES 한 곳에서 나온다(authorize 와 클라이언트가
|
||||||
|
# 어긋나지 않게). 늘어날 때 이 테스트도 함께 갱신할 것.
|
||||||
|
assert config.scope_param == ",".join(cfgmod.DEFAULT_SCOPES)
|
||||||
|
assert "mall.read_product" in config.scope_param
|
||||||
|
assert "mall.write_product" in config.scope_param
|
||||||
|
|
||||||
|
|
||||||
|
def test_product_url_uses_shop_url_env():
|
||||||
|
"""다이렉트 주소는 CAFE24_SHOP_URL 기준. 스킴·끝 슬래시가 없어도 맞춘다."""
|
||||||
|
saved = os.environ.get("CAFE24_SHOP_URL")
|
||||||
|
try:
|
||||||
|
for value in ("https://miras.co.kr", "miras.co.kr", "https://miras.co.kr/"):
|
||||||
|
os.environ["CAFE24_SHOP_URL"] = value
|
||||||
|
config = _config()
|
||||||
|
assert (
|
||||||
|
config.product_url(119)
|
||||||
|
== "https://miras.co.kr/product/detail.html?product_no=119"
|
||||||
|
), value
|
||||||
|
finally:
|
||||||
|
if saved is None:
|
||||||
|
os.environ.pop("CAFE24_SHOP_URL", None)
|
||||||
|
else:
|
||||||
|
os.environ["CAFE24_SHOP_URL"] = saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_product_url_falls_back_to_cafe24_domain():
|
||||||
|
"""CAFE24_SHOP_URL 이 없어도 항상 유효한 주소가 나와야 한다."""
|
||||||
|
saved = os.environ.pop("CAFE24_SHOP_URL", None)
|
||||||
|
try:
|
||||||
|
assert _config().product_url(119) == (
|
||||||
|
"https://testmall.cafe24.com/product/detail.html?product_no=119"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if saved is not None:
|
||||||
|
os.environ["CAFE24_SHOP_URL"] = saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_authorize_url_has_state_and_no_secret():
|
||||||
|
url = oauth.build_authorize_url(_config(), state="STATE123")
|
||||||
|
assert url.startswith("https://testmall.cafe24api.com/api/v2/oauth/authorize?")
|
||||||
|
assert "state=STATE123" in url
|
||||||
|
# client_secret 은 authorize 단계에 절대 실리면 안 된다.
|
||||||
|
assert "csecret" not in url
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 토큰 상태 / 자동 갱신
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def test_status_without_token():
|
||||||
|
status = tokens.TokenService(_FakeRepo(None), _config()).status()
|
||||||
|
assert status["connected"] is False
|
||||||
|
assert status["needs_reauth"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_never_leaks_token_values():
|
||||||
|
service = tokens.TokenService(_FakeRepo(_row()), _config())
|
||||||
|
status = service.status()
|
||||||
|
assert status["connected"] is True
|
||||||
|
assert "AT" not in str(status) and "RT" not in str(status)
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_token_returned_without_refresh():
|
||||||
|
service = tokens.TokenService(_FakeRepo(_row()), _config())
|
||||||
|
assert service.get_access_token() == "AT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_refresh_token_needs_reauth():
|
||||||
|
row = _row(refresh_token_expires_at=now_kst() - timedelta(days=1))
|
||||||
|
assert tokens.TokenService(_FakeRepo(row), _config()).status()["needs_reauth"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_access_token_triggers_refresh():
|
||||||
|
"""만료된 access token 은 refresh 후 새 값을 돌려주고, 저장은 암호문으로 한다."""
|
||||||
|
repo = _FakeRepo(_row(access_token_expires_at=now_kst() - timedelta(minutes=5)))
|
||||||
|
service = tokens.TokenService(repo, _config())
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def fake_refresh(config, *, refresh_token):
|
||||||
|
seen.append(refresh_token)
|
||||||
|
return oauth.TokenBundle(
|
||||||
|
access_token="NEW-AT",
|
||||||
|
refresh_token="NEW-RT",
|
||||||
|
access_token_expires_at=now_kst() + timedelta(hours=2),
|
||||||
|
refresh_token_expires_at=now_kst() + timedelta(days=14),
|
||||||
|
scopes="mall.read_product,mall.write_product",
|
||||||
|
)
|
||||||
|
|
||||||
|
original = tokens.refresh_tokens
|
||||||
|
tokens.refresh_tokens = fake_refresh
|
||||||
|
try:
|
||||||
|
assert service.get_access_token() == "NEW-AT"
|
||||||
|
finally:
|
||||||
|
tokens.refresh_tokens = original
|
||||||
|
|
||||||
|
assert seen == ["RT"] # 복호화된 refresh token 이 전달돼야 한다
|
||||||
|
saved = repo.saves[-1]
|
||||||
|
assert saved["access_token"] != "NEW-AT" # 평문 저장 금지
|
||||||
|
assert crypto.decrypt(saved["access_token"], secret=SECRET) == "NEW-AT"
|
||||||
|
assert saved["last_error"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_dead_refresh_token_raises_auth_error():
|
||||||
|
row = _row(
|
||||||
|
access_token_expires_at=now_kst() - timedelta(minutes=1),
|
||||||
|
refresh_token_expires_at=now_kst() - timedelta(days=1),
|
||||||
|
)
|
||||||
|
service = tokens.TokenService(_FakeRepo(row), _config())
|
||||||
|
try:
|
||||||
|
service.get_access_token()
|
||||||
|
except Cafe24AuthError as exc:
|
||||||
|
assert exc.needs_reauth is True
|
||||||
|
return
|
||||||
|
raise AssertionError("refresh token 만료 시 Cafe24AuthError 여야 한다")
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# store.py 순수 로직
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def test_schedule_editable_only_when_pending():
|
||||||
|
assert store.is_editable("PENDING") is True
|
||||||
|
for locked in ("PROCESSING", "SUCCESS", "FAILED", "CANCELLED"):
|
||||||
|
assert store.is_editable(locked) is False, locked
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_budget_and_backoff():
|
||||||
|
assert all(store.can_retry(i) for i in range(store.MAX_RETRY))
|
||||||
|
assert store.can_retry(store.MAX_RETRY) is False
|
||||||
|
# 무한 재시도 방지 — 간격은 증가하되 상한이 있다.
|
||||||
|
assert store.retry_backoff_seconds(0) < store.retry_backoff_seconds(1)
|
||||||
|
assert store.retry_backoff_seconds(99) == store.retry_backoff_seconds(2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_revision_type():
|
||||||
|
assert store.normalize_revision_type("backup") == store.REVISION_BACKUP
|
||||||
|
assert store.normalize_revision_type("nope") == store.REVISION_DRAFT
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_product_no():
|
||||||
|
assert store.parse_product_no(" 123 ") == 123
|
||||||
|
for bad in ("abc", "0", "-3", None, ""):
|
||||||
|
try:
|
||||||
|
store.parse_product_no(bad)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
raise AssertionError(f"{bad!r} 는 거부해야 한다")
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 상품 엔드포인트 래퍼
|
||||||
|
# 실제 쇼핑몰 확인 결과 /admin/products/{no}/description 은 존재하지 않는다
|
||||||
|
# (`No API found.`). 상세설명은 상품 리소스의 필드다 — 경로가 되돌아가지 않게
|
||||||
|
# 여기서 고정한다.
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
class _FakeClient:
|
||||||
|
"""Cafe24Client 의 get/put 만 흉내내고 호출을 기록한다."""
|
||||||
|
|
||||||
|
def __init__(self, payload=None):
|
||||||
|
self.payload = payload or {}
|
||||||
|
self.calls: list[dict] = []
|
||||||
|
|
||||||
|
def get(self, path, *, params=None, json=None, product_no=None):
|
||||||
|
self.calls.append({"method": "GET", "path": path, "params": params})
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
def put(self, path, *, params=None, json=None, product_no=None):
|
||||||
|
self.calls.append({"method": "PUT", "path": path, "json": json})
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
|
||||||
|
_PRODUCT = {
|
||||||
|
"product_no": 131,
|
||||||
|
"product_code": "P000000B",
|
||||||
|
"product_name": "빠져락 1개(사은품)",
|
||||||
|
"display": "T",
|
||||||
|
"selling": "F",
|
||||||
|
"description": "<p>PC</p>",
|
||||||
|
"mobile_description": "<p>PC</p>",
|
||||||
|
"separated_mobile_description": "F",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_descriptions_from_product():
|
||||||
|
desc = products.descriptions_from_product(_PRODUCT)
|
||||||
|
assert desc.product_no == 131
|
||||||
|
assert desc.description == "<p>PC</p>"
|
||||||
|
assert desc.separated_mobile is False
|
||||||
|
assert desc.mobile_differs is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_descriptions_separated_mobile_and_diff():
|
||||||
|
desc = products.descriptions_from_product(
|
||||||
|
{**_PRODUCT, "separated_mobile_description": "T", "mobile_description": "<p>MO</p>"}
|
||||||
|
)
|
||||||
|
assert desc.separated_mobile is True
|
||||||
|
assert desc.mobile_differs is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_descriptions_uses_product_resource():
|
||||||
|
client = _FakeClient({"product": _PRODUCT})
|
||||||
|
desc = products.fetch_descriptions(client, 131)
|
||||||
|
assert desc.description == "<p>PC</p>"
|
||||||
|
paths = [c["path"] for c in client.calls]
|
||||||
|
assert paths == ["/admin/products/131"], paths
|
||||||
|
assert not any(p.endswith("/description") for p in paths)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_descriptions_payload():
|
||||||
|
client = _FakeClient({"product": _PRODUCT})
|
||||||
|
products.update_descriptions(client, 131, description="<p>NEW</p>")
|
||||||
|
call = client.calls[0]
|
||||||
|
assert call["method"] == "PUT" and call["path"] == "/admin/products/131"
|
||||||
|
# 준 필드만 바뀌어야 한다 — 모바일을 지정하지 않으면 보내지 않는다.
|
||||||
|
assert call["json"] == {"request": {"description": "<p>NEW</p>"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_payload_optional_fields():
|
||||||
|
both = products.build_update_payload(
|
||||||
|
description="<p>PC</p>", mobile_description="<p>MO</p>", shop_no=1
|
||||||
|
)
|
||||||
|
assert both == {"shop_no": 1, "request": {"description": "<p>PC</p>", "mobile_description": "<p>MO</p>"}}
|
||||||
|
# 빈 문자열은 "모바일을 비운다"는 뜻이므로 None 과 구분해 전달돼야 한다.
|
||||||
|
assert products.build_update_payload(description="x", mobile_description="")["request"] == {
|
||||||
|
"description": "x",
|
||||||
|
"mobile_description": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_product_flags():
|
||||||
|
row = products.normalize_product(_PRODUCT)
|
||||||
|
assert row == {
|
||||||
|
"product_no": 131,
|
||||||
|
"product_code": "P000000B",
|
||||||
|
"product_name": "빠져락 1개(사은품)",
|
||||||
|
"display": True,
|
||||||
|
"selling": False,
|
||||||
|
}
|
||||||
|
# 값이 없으면 기본 True(카페24 응답에 필드가 빠진 경우 진열 중으로 본다).
|
||||||
|
assert products.normalize_product({"product_no": "9"})["display"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class _PagingClient:
|
||||||
|
"""페이지를 넘겨가며 응답하는 가짜 클라이언트."""
|
||||||
|
|
||||||
|
def __init__(self, count: int):
|
||||||
|
self.count = count
|
||||||
|
self.calls: list[dict] = []
|
||||||
|
|
||||||
|
def get(self, path, *, params=None, json=None, product_no=None):
|
||||||
|
self.calls.append(dict(params or {}))
|
||||||
|
offset = int((params or {}).get("offset", 0))
|
||||||
|
limit = int((params or {}).get("limit", 100))
|
||||||
|
page = [{"product_no": n} for n in range(offset, min(offset + limit, self.count))]
|
||||||
|
return {"products": page}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_all_products_walks_pages():
|
||||||
|
client = _PagingClient(230)
|
||||||
|
rows, truncated = products.list_all_products(client)
|
||||||
|
assert len(rows) == 230 and truncated is False
|
||||||
|
# 100 + 100 + 30 → 3회 호출로 끝나야 한다.
|
||||||
|
assert len(client.calls) == 3
|
||||||
|
assert [c["offset"] for c in client.calls] == [0, 100, 200]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_all_products_stops_at_cap():
|
||||||
|
"""상한을 넘으면 잘렸다고 알린다 — 무한 호출로 API 제한에 걸리지 않게."""
|
||||||
|
client = _PagingClient(10_000)
|
||||||
|
rows, truncated = products.list_all_products(client, max_items=150)
|
||||||
|
assert len(rows) == 150 and truncated is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_all_products_single_page():
|
||||||
|
"""현재 쇼핑몰(87개)은 1회 호출로 끝난다."""
|
||||||
|
client = _PagingClient(87)
|
||||||
|
rows, truncated = products.list_all_products(client)
|
||||||
|
assert len(rows) == 87 and truncated is False
|
||||||
|
assert len(client.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_products_clamps_paging():
|
||||||
|
client = _FakeClient({"products": []})
|
||||||
|
products.list_products(client, limit=999, offset=-5, product_name="락")
|
||||||
|
params = client.calls[0]["params"]
|
||||||
|
assert params["limit"] == products.PAGE_LIMIT
|
||||||
|
assert params["offset"] == 0
|
||||||
|
assert params["product_name"] == "락"
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 이미지 URL 한글 파일명 표시 ↔ 저장 (왕복 보존이 핵심)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
_REAL_IMG = "%EC%9A%A9%EA%B8%B0%EB%83%84%EC%83%88%EC%B0%A8%EB%8B%A8(%ED%99%A9%ED%86%A0)_12.gif"
|
||||||
|
_REAL_HTML = f'<img src="/web/product/big/{_REAL_IMG}" alt="용기 냄새차단">'
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_shows_korean_filename():
|
||||||
|
decoded = store.decode_html_urls(_REAL_HTML)
|
||||||
|
assert "용기냄새차단(황토)_12.gif" in decoded
|
||||||
|
assert "%EC%9A%A9" not in decoded
|
||||||
|
# 괄호는 원본에서 인코딩돼 있지 않으므로 그대로 남아야 한다.
|
||||||
|
assert "(황토)" in decoded
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_roundtrip_is_byte_identical():
|
||||||
|
"""편집하지 않고 적용해도 카페24 저장값이 달라지면 안 된다."""
|
||||||
|
assert store.encode_html_urls(store.decode_html_urls(_REAL_HTML)) == _REAL_HTML
|
||||||
|
|
||||||
|
|
||||||
|
def test_ascii_escapes_are_not_decoded():
|
||||||
|
"""%20·%3C 를 풀면 URL·HTML 구조가 깨진다 — 건드리지 않는다."""
|
||||||
|
html = '<img src="/web/a%20b.png?x=1%3C2">'
|
||||||
|
assert store.decode_html_urls(html) == html
|
||||||
|
assert store.encode_html_urls(html) == html
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_leaves_body_text_alone():
|
||||||
|
"""본문 한글은 인코딩 대상이 아니다(URL 속성값만 바꾼다)."""
|
||||||
|
html = '<p>여름 특가 안내</p><a href="/web/여름.html">보기</a>'
|
||||||
|
encoded = store.encode_html_urls(html)
|
||||||
|
assert "<p>여름 특가 안내</p>" in encoded
|
||||||
|
assert 'href="/web/%EC%97%AC%EB%A6%84.html"' in encoded
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_url_is_handled():
|
||||||
|
html = "<style>.a{background:url(/web/upload/%ED%99%A9%ED%86%A0.png)}</style>"
|
||||||
|
assert "황토.png" in store.decode_html_urls(html)
|
||||||
|
assert store.encode_html_urls(store.decode_html_urls(html)) == html
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_utf8_sequence_left_alone():
|
||||||
|
"""EUC-KR 등 UTF-8 이 아닌 이스케이프는 깨뜨리지 않고 그대로 둔다."""
|
||||||
|
html = '<img src="/web/%C7%CF%B3%AA.gif">'
|
||||||
|
assert store.decode_html_urls(html) == html
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 소스 정리(포맷) — 렌더링을 바꾸지 않는 것이 최우선
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
_MESSY = (
|
||||||
|
'<style>\n\t/* 주석 */\n\t.v{max-width:100%}\n</style>'
|
||||||
|
'<div class="wrap"><p>안녕<span>하세요</span> 여름 특가</p>'
|
||||||
|
'<img src="/web/a.gif"><img src="/web/b.gif">'
|
||||||
|
"<table><tr><td>1</td><td>2</td></tr></table></div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_breaks_block_tags():
|
||||||
|
out = store.format_html(_MESSY)
|
||||||
|
lines = out.split("\n")
|
||||||
|
assert '<div class="wrap">' in lines
|
||||||
|
assert "</div>" in lines
|
||||||
|
# 블록 안쪽은 들여쓴다.
|
||||||
|
assert any(line.startswith(" <table>") for line in lines)
|
||||||
|
assert any(line.startswith(" <td>") for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_keeps_inline_elements_together():
|
||||||
|
"""이미지 사이에 줄바꿈이 들어가면 화면에 공백이 생긴다 — 붙여둬야 한다."""
|
||||||
|
out = store.format_html(_MESSY)
|
||||||
|
assert '<img src="/web/a.gif"><img src="/web/b.gif">' in out
|
||||||
|
assert "<p>안녕<span>하세요</span> 여름 특가</p>" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_preserves_style_content_verbatim():
|
||||||
|
out = store.format_html(_MESSY)
|
||||||
|
assert "\t/* 주석 */" in out
|
||||||
|
assert "\t.v{max-width:100%}" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_is_idempotent():
|
||||||
|
"""편집하지 않고 다시 적용해도 저장값이 계속 바뀌면 안 된다."""
|
||||||
|
once = store.format_html(_MESSY)
|
||||||
|
assert store.format_html(once) == once
|
||||||
|
assert store.format_html(store.format_html(once)) == once
|
||||||
|
|
||||||
|
|
||||||
|
_REAL_DETAIL = """<div style="width: 1000px; margin: 0 auto;">
|
||||||
|
|
||||||
|
<img src="../img/promo/dadamam_detail1.jpg">
|
||||||
|
<img src="../img/promo/dadamam_detail2.jpg">
|
||||||
|
|
||||||
|
<!-- 대파_타임랩스----------------><img contenteditable="false" src="../img/gif/NEW_1.gif">
|
||||||
|
<img contenteditable="false" src="../img/2+1/2+1_02.jpg">
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_indents_every_line_of_a_run():
|
||||||
|
"""원문 줄바꿈을 살리고 **모든 줄**을 들여쓴다.
|
||||||
|
|
||||||
|
예전에는 첫 줄만 들여쓰고 나머지가 1열에 붙어 나왔다.
|
||||||
|
"""
|
||||||
|
lines = store.format_html(_REAL_DETAIL).split("\n")
|
||||||
|
img_lines = [line for line in lines if "<img" in line]
|
||||||
|
assert len(img_lines) == 4, img_lines
|
||||||
|
assert all(line.startswith(" <") for line in img_lines), img_lines
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_keeps_comment_with_its_element():
|
||||||
|
"""`<!-- 라벨 --><img>` 는 붙여둔다 — 나누면 라벨과 대상이 떨어진다."""
|
||||||
|
out = store.format_html(_REAL_DETAIL)
|
||||||
|
assert "<!-- 대파_타임랩스----------------><img contenteditable=" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_keeps_single_blank_line():
|
||||||
|
"""구획용 빈 줄은 한 줄까지 유지한다(여러 줄은 하나로)."""
|
||||||
|
out = store.format_html("<div>\n\n\n<img src=\"a.gif\">\n\n\n<img src=\"b.gif\">\n</div>")
|
||||||
|
assert "\n\n" in out
|
||||||
|
assert "\n\n\n" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_real_detail_is_idempotent():
|
||||||
|
once = store.format_html(_REAL_DETAIL)
|
||||||
|
assert store.format_html(once) == once
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_collapses_short_blocks():
|
||||||
|
assert store.format_html("<td>1</td>") == "<td>1</td>"
|
||||||
|
# 길면 나눈다.
|
||||||
|
long_text = "가" * 200
|
||||||
|
assert "\n" in store.format_html("<td>%s</td>" % long_text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_survives_broken_html():
|
||||||
|
"""닫는 태그 누락·꺾쇠 조각이 있어도 예외 없이 뭔가를 돌려준다."""
|
||||||
|
for bad in ("<div><p>열고 안 닫음", "a < b 그리고 c > d", "<<>>", "<div", ""):
|
||||||
|
assert isinstance(store.format_html(bad), str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_does_not_touch_urls():
|
||||||
|
"""포맷은 속성값을 건드리지 않는다(인코딩과 서로 간섭하지 않게)."""
|
||||||
|
html = '<div><img src="/web/%EC%9A%A9%EA%B8%B0(a)_1.gif"></div>'
|
||||||
|
assert "/web/%EC%9A%A9%EA%B8%B0(a)_1.gif" in store.format_html(html)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_then_encode_roundtrip():
|
||||||
|
"""화면 표시(디코딩+정리) → 저장(인코딩+정리) 순서에서 URL 이 원형을 지킨다."""
|
||||||
|
raw = store.format_html(_REAL_HTML)
|
||||||
|
shown = store.format_html(store.decode_html_urls(raw))
|
||||||
|
saved = store.format_html(store.encode_html_urls(shown))
|
||||||
|
assert saved == raw
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 예약 — 입력 검증
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def test_parse_tristate():
|
||||||
|
assert store.parse_tristate("on") is True
|
||||||
|
assert store.parse_tristate("off") is False
|
||||||
|
for keep in ("", None, "keep", "이상한값"):
|
||||||
|
assert store.parse_tristate(keep) is None, keep
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_schedule_at_accepts_future_kst():
|
||||||
|
base = now_kst()
|
||||||
|
target = (base + timedelta(hours=3)).replace(second=0, microsecond=0)
|
||||||
|
parsed = store.parse_schedule_at(target.strftime("%Y-%m-%dT%H:%M"), now=base)
|
||||||
|
assert parsed.utcoffset() == timedelta(hours=9) # 타임존 표기 없는 입력을 KST 로 해석
|
||||||
|
assert parsed.hour == target.hour and parsed.minute == target.minute
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_schedule_at_rejects_past_and_garbage():
|
||||||
|
base = now_kst()
|
||||||
|
past = (base - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M")
|
||||||
|
for bad in (past, "", "어제", "2026-13-45T99:99"):
|
||||||
|
try:
|
||||||
|
store.parse_schedule_at(bad, now=base)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
raise AssertionError(f"{bad!r} 는 거부해야 한다")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_schedule_at_allows_one_minute_grace():
|
||||||
|
"""`datetime-local` 은 초를 버린다 — 지금 이 분(分)을 고른 것을 거부하면 안 된다.
|
||||||
|
|
||||||
|
now 의 초를 고정해 실행 시각에 따라 결과가 달라지지 않게 한다(예전에 이 테스트가
|
||||||
|
초에 따라 실패했다).
|
||||||
|
"""
|
||||||
|
base = now_kst().replace(second=40, microsecond=0)
|
||||||
|
this_minute = base.strftime("%Y-%m-%dT%H:%M") # 초가 잘려 base 보다 40초 과거
|
||||||
|
assert store.parse_schedule_at(this_minute, now=base) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_schedule_action():
|
||||||
|
assert store.describe_schedule_action(
|
||||||
|
has_html=True, set_display=True, set_selling=False
|
||||||
|
) == "상세페이지 · 진열 · 판매중지"
|
||||||
|
assert store.describe_schedule_action(
|
||||||
|
has_html=False, set_display=None, set_selling=True
|
||||||
|
) == "판매"
|
||||||
|
assert store.describe_schedule_action(
|
||||||
|
has_html=False, set_display=None, set_selling=None
|
||||||
|
) == "없음"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_schedule_status():
|
||||||
|
assert store.normalize_schedule_status("success") == store.STATUS_SUCCESS
|
||||||
|
assert store.normalize_schedule_status("없는상태") == store.STATUS_PENDING
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 예약 — 상품 수정 payload (진열/판매 포함)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def test_update_payload_flags():
|
||||||
|
payload = products.build_update_payload(display=True, selling=False)
|
||||||
|
assert payload == {"request": {"display": "T", "selling": "F"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_payload_skips_none():
|
||||||
|
"""None 인 항목은 아예 보내지 않는다 = 그 필드를 건드리지 않는다."""
|
||||||
|
payload = products.build_update_payload(description="<p>x</p>")
|
||||||
|
assert payload["request"] == {"description": "<p>x</p>"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_product_skips_empty_request():
|
||||||
|
"""바꿀 것이 없으면 API 를 호출하지 않는다."""
|
||||||
|
client = _FakeClient({"product": _PRODUCT})
|
||||||
|
assert products.update_product(client, 131) == {}
|
||||||
|
assert client.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_product_sends_flags_and_html():
|
||||||
|
client = _FakeClient({"product": _PRODUCT})
|
||||||
|
products.update_product(client, 131, description="<p>새</p>", display=False)
|
||||||
|
call = client.calls[0]
|
||||||
|
assert call["method"] == "PUT" and call["path"] == "/admin/products/131"
|
||||||
|
assert call["json"] == {"request": {"description": "<p>새</p>", "display": "F"}}
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 예약 worker — 성공/재시도/최종실패
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
class _FakeStore:
|
||||||
|
"""worker 가 쓰는 저장소 메서드만 흉내낸다."""
|
||||||
|
|
||||||
|
def __init__(self, rows):
|
||||||
|
self.rows = list(rows)
|
||||||
|
self.revisions = {}
|
||||||
|
self.added = []
|
||||||
|
self.finished = []
|
||||||
|
self.audits = []
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def claim_due_schedule(self, *, now):
|
||||||
|
yield self.rows.pop(0) if self.rows else None
|
||||||
|
|
||||||
|
def get_revision(self, revision_id):
|
||||||
|
return self.revisions.get(int(revision_id), {})
|
||||||
|
|
||||||
|
def add_revision(self, **fields):
|
||||||
|
self.added.append(fields)
|
||||||
|
return 900 + len(self.added)
|
||||||
|
|
||||||
|
def finish_schedule(self, schedule_id, *, status, error="", next_retry_at=None, retry_count=None):
|
||||||
|
self.finished.append(
|
||||||
|
{"id": schedule_id, "status": status, "error": error,
|
||||||
|
"next_retry_at": next_retry_at, "retry_count": retry_count}
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_audit(self, **fields):
|
||||||
|
self.audits.append(fields)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeApi:
|
||||||
|
def __init__(self, client):
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
|
||||||
|
class _WorkerClient(_FakeClient):
|
||||||
|
"""PUT 을 실패시킬 수 있는 클라이언트."""
|
||||||
|
|
||||||
|
def __init__(self, payload=None, fail_put=None):
|
||||||
|
super().__init__(payload)
|
||||||
|
self.fail_put = fail_put
|
||||||
|
|
||||||
|
def put(self, path, *, params=None, json=None, product_no=None):
|
||||||
|
if self.fail_put:
|
||||||
|
raise self.fail_put
|
||||||
|
return super().put(path, params=params, json=json, product_no=product_no)
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_row(**overrides):
|
||||||
|
row = {
|
||||||
|
"id": 7, "product_no": 131, "revision_id": 55,
|
||||||
|
"set_display": True, "set_selling": None, "retry_count": 0,
|
||||||
|
}
|
||||||
|
row.update(overrides)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_applies_html_and_flags():
|
||||||
|
st = _FakeStore([_schedule_row()])
|
||||||
|
st.revisions[55] = {"html_content": "<p>예약 내용</p>"}
|
||||||
|
client = _WorkerClient({"product": _PRODUCT})
|
||||||
|
assert worker.process_once(st, _FakeApi(client)) == 1
|
||||||
|
|
||||||
|
# 쓰기 직전 현재값을 읽어 BACKUP 을 남겼는가
|
||||||
|
assert any(r["revision_type"] == store.REVISION_BACKUP for r in st.added)
|
||||||
|
# HTML 과 진열 상태를 한 번의 PUT 으로 보냈는가
|
||||||
|
put = [c for c in client.calls if c["method"] == "PUT"][0]
|
||||||
|
assert put["json"]["request"]["description"] == "<p>예약 내용</p>"
|
||||||
|
assert put["json"]["request"]["mobile_description"] == "<p>예약 내용</p>"
|
||||||
|
assert put["json"]["request"]["display"] == "T"
|
||||||
|
assert "selling" not in put["json"]["request"] # 변경 없음이면 보내지 않는다
|
||||||
|
assert st.finished == [
|
||||||
|
{"id": 7, "status": store.STATUS_SUCCESS, "error": "",
|
||||||
|
"next_retry_at": None, "retry_count": None}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_flags_only_skips_backup():
|
||||||
|
"""HTML 없이 진열/판매만 바꾸는 예약은 상세설명을 읽거나 백업하지 않는다."""
|
||||||
|
st = _FakeStore([_schedule_row(revision_id=None, set_selling=False)])
|
||||||
|
client = _WorkerClient({"product": _PRODUCT})
|
||||||
|
assert worker.process_once(st, _FakeApi(client)) == 1
|
||||||
|
assert st.added == [] # 백업 없음
|
||||||
|
put = [c for c in client.calls if c["method"] == "PUT"][0]
|
||||||
|
assert "description" not in put["json"]["request"]
|
||||||
|
assert put["json"]["request"] == {"display": "T", "selling": "F"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_retries_then_fails():
|
||||||
|
"""실패는 재시도 예산 안에서 다시 시도하고, 소진되면 FAILED 로 확정한다."""
|
||||||
|
boom = Cafe24ApiError("서버 오류", status=500)
|
||||||
|
|
||||||
|
st = _FakeStore([_schedule_row(retry_count=0)])
|
||||||
|
st.revisions[55] = {"html_content": "<p>x</p>"}
|
||||||
|
worker.process_once(st, _FakeApi(_WorkerClient({"product": _PRODUCT}, fail_put=boom)))
|
||||||
|
first = st.finished[0]
|
||||||
|
assert first["status"] == store.STATUS_PENDING # 다시 대기로
|
||||||
|
assert first["retry_count"] == 1
|
||||||
|
assert first["next_retry_at"] is not None
|
||||||
|
|
||||||
|
st2 = _FakeStore([_schedule_row(retry_count=store.MAX_RETRY)])
|
||||||
|
st2.revisions[55] = {"html_content": "<p>x</p>"}
|
||||||
|
worker.process_once(st2, _FakeApi(_WorkerClient({"product": _PRODUCT}, fail_put=boom)))
|
||||||
|
assert st2.finished[0]["status"] == store.STATUS_FAILED
|
||||||
|
assert any(a["result"] == "FAIL" for a in st2.audits)
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_missing_revision_is_failure_not_crash():
|
||||||
|
st = _FakeStore([_schedule_row(revision_id=999, retry_count=store.MAX_RETRY)])
|
||||||
|
worker.process_once(st, _FakeApi(_WorkerClient({"product": _PRODUCT})))
|
||||||
|
assert st.finished[0]["status"] == store.STATUS_FAILED
|
||||||
|
assert "999" in st.finished[0]["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_stops_when_nothing_due():
|
||||||
|
st = _FakeStore([])
|
||||||
|
assert worker.process_once(st, _FakeApi(_WorkerClient())) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _run_all():
|
||||||
|
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||||
|
for fn in fns:
|
||||||
|
fn()
|
||||||
|
print("PASS", fn.__name__)
|
||||||
|
print(f"\n{len(fns)} tests passed.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_run_all()
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""카페24 예약 실행 worker.
|
||||||
|
|
||||||
|
python -m app.modules.cafe24.worker --loop 60 # 60초마다 확인 (운영)
|
||||||
|
python -m app.modules.cafe24.worker --once # 한 번만 처리하고 종료
|
||||||
|
|
||||||
|
왜 별도 프로세스인가
|
||||||
|
예약은 브라우저를 닫아도, 아무도 화면을 보고 있지 않아도 그 시각에 실행돼야 한다.
|
||||||
|
웹 요청 안에서 기다리는 방식은 프록시 타임아웃·재기동에 그대로 무너진다.
|
||||||
|
compose 서비스 `dbx-cafe24-worker` 가 web 과 같은 이미지로 이 모듈을 돌린다.
|
||||||
|
|
||||||
|
한 번에 한 건씩 처리한다
|
||||||
|
`claim_due_schedule` 이 `FOR UPDATE SKIP LOCKED` 로 한 건을 잠그고 PROCESSING 으로
|
||||||
|
바꾼다. worker 가 실수로 두 개 떠도 같은 예약이 두 번 적용되지 않는다.
|
||||||
|
|
||||||
|
적용 순서는 화면 편집과 같다
|
||||||
|
카페24 현재값 재조회 → BACKUP revision → PUT → SUCCESS + 감사로그
|
||||||
|
실패하면 재시도 예산(store.MAX_RETRY) 안에서 간격을 두고 다시 시도하고,
|
||||||
|
소진되면 FAILED 로 확정한다. 되돌리기는 쓰지 않는다.
|
||||||
|
|
||||||
|
토큰 갱신은 TokenService 가 행 잠금 안에서 하므로 web 과 동시에 떠 있어도 안전하다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
|
||||||
|
from app.timezone import now_kst
|
||||||
|
|
||||||
|
from . import store
|
||||||
|
|
||||||
|
logger = logging.getLogger("cafe24.worker")
|
||||||
|
|
||||||
|
ACTOR = "SCHEDULER"
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(store_db: Any, api: Any, row: dict[str, Any]) -> str:
|
||||||
|
"""예약 1건 적용. 성공 시 사람이 읽을 요약 문자열."""
|
||||||
|
schedule_id = int(row["id"])
|
||||||
|
product_no = int(row["product_no"])
|
||||||
|
revision_id = row.get("revision_id")
|
||||||
|
set_display = row.get("set_display")
|
||||||
|
set_selling = row.get("set_selling")
|
||||||
|
|
||||||
|
html: str | None = None
|
||||||
|
if revision_id:
|
||||||
|
revision = store_db.get_revision(int(revision_id))
|
||||||
|
if not revision:
|
||||||
|
raise Cafe24Error(f"예약이 가리키는 버전 {revision_id} 을 찾을 수 없습니다.")
|
||||||
|
html = revision.get("html_content") or ""
|
||||||
|
if not html.strip():
|
||||||
|
raise Cafe24Error(f"버전 {revision_id} 의 내용이 비어 있습니다.")
|
||||||
|
|
||||||
|
# 상세설명을 바꿀 때는 쓰기 직전 현재값을 읽어 백업한다(로컬 값을 믿지 않는다).
|
||||||
|
backup_id = 0
|
||||||
|
mobile_html: str | None = None
|
||||||
|
if html is not None:
|
||||||
|
current = products.fetch_descriptions(api.client, product_no)
|
||||||
|
backup_id = store_db.add_revision(
|
||||||
|
product_no=product_no,
|
||||||
|
html_content=current.description,
|
||||||
|
revision_type=store.REVISION_BACKUP,
|
||||||
|
memo=f"예약 #{schedule_id} 적용 직전 자동 백업",
|
||||||
|
created_by=ACTOR,
|
||||||
|
)
|
||||||
|
if current.mobile_description and current.mobile_description != current.description:
|
||||||
|
store_db.add_revision(
|
||||||
|
product_no=product_no,
|
||||||
|
html_content=current.mobile_description,
|
||||||
|
revision_type=store.REVISION_BACKUP,
|
||||||
|
memo=f"예약 #{schedule_id} 적용 직전 자동 백업 (모바일)",
|
||||||
|
created_by=ACTOR,
|
||||||
|
)
|
||||||
|
# PC/모바일은 구분하지 않는다 — 화면 편집과 같은 방침.
|
||||||
|
mobile_html = html
|
||||||
|
|
||||||
|
products.update_product(
|
||||||
|
api.client,
|
||||||
|
product_no,
|
||||||
|
description=html,
|
||||||
|
mobile_description=mobile_html,
|
||||||
|
display=set_display,
|
||||||
|
selling=set_selling,
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = store.describe_schedule_action(
|
||||||
|
has_html=html is not None, set_display=set_display, set_selling=set_selling
|
||||||
|
)
|
||||||
|
store_db.log_audit(
|
||||||
|
actor=ACTOR,
|
||||||
|
action="schedule_apply",
|
||||||
|
product_no=product_no,
|
||||||
|
revision_id=int(revision_id) if revision_id else None,
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
result="SUCCESS",
|
||||||
|
detail=summary + (f" (백업 {backup_id})" if backup_id else ""),
|
||||||
|
)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def process_once(store_db: Any, api: Any) -> int:
|
||||||
|
"""실행할 예약을 모두 처리한다. 처리한 건수를 돌려준다."""
|
||||||
|
handled = 0
|
||||||
|
while True:
|
||||||
|
with store_db.claim_due_schedule(now=now_kst()) as row:
|
||||||
|
if row is None:
|
||||||
|
return handled
|
||||||
|
# 잠금은 여기서 이미 풀렸다. 상태가 PROCESSING 이라 다른 worker 가 집지 않는다.
|
||||||
|
schedule_id = int(row["id"])
|
||||||
|
product_no = int(row["product_no"])
|
||||||
|
try:
|
||||||
|
summary = _apply(store_db, api, row)
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
retry_count = int(row.get("retry_count") or 0)
|
||||||
|
if store.can_retry(retry_count):
|
||||||
|
wait = store.retry_backoff_seconds(retry_count)
|
||||||
|
store_db.finish_schedule(
|
||||||
|
schedule_id,
|
||||||
|
status=store.STATUS_PENDING,
|
||||||
|
error=str(exc),
|
||||||
|
next_retry_at=now_kst() + timedelta(seconds=wait),
|
||||||
|
retry_count=retry_count + 1,
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"예약 #%s 상품 %s 실패 — %s초 후 재시도 (%s/%s): %s",
|
||||||
|
schedule_id, product_no, wait, retry_count + 1, store.MAX_RETRY, exc,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
store_db.finish_schedule(
|
||||||
|
schedule_id, status=store.STATUS_FAILED, error=str(exc)
|
||||||
|
)
|
||||||
|
store_db.log_audit(
|
||||||
|
actor=ACTOR, action="schedule_apply", product_no=product_no,
|
||||||
|
schedule_id=schedule_id, result="FAIL", detail=str(exc),
|
||||||
|
)
|
||||||
|
logger.error("예약 #%s 상품 %s 최종 실패: %s", schedule_id, product_no, exc)
|
||||||
|
except Exception as exc: # noqa: BLE001 — 한 건의 사고가 worker 를 죽이면 안 된다.
|
||||||
|
store_db.finish_schedule(
|
||||||
|
schedule_id, status=store.STATUS_FAILED, error=f"{type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
logger.exception("예약 #%s 처리 중 예상치 못한 오류", schedule_id)
|
||||||
|
else:
|
||||||
|
store_db.finish_schedule(schedule_id, status=store.STATUS_SUCCESS)
|
||||||
|
logger.info("예약 #%s 상품 %s 적용 완료 — %s", schedule_id, product_no, summary)
|
||||||
|
handled += 1
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="카페24 예약 실행 worker")
|
||||||
|
parser.add_argument("--loop", type=int, default=0, help="확인 간격(초). 0 이면 한 번만")
|
||||||
|
parser.add_argument("--once", action="store_true", help="한 번만 처리하고 종료")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
)
|
||||||
|
|
||||||
|
dsn = (os.getenv("CAFE24_DB_URL") or "").strip()
|
||||||
|
if not dsn:
|
||||||
|
logger.error("CAFE24_DB_URL 이 설정되지 않았습니다. worker 를 시작할 수 없습니다.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 지연 import — psycopg 가 없는 개발 환경에서도 이 모듈을 열어볼 수 있게.
|
||||||
|
from .db import Cafe24Store # noqa: WPS433
|
||||||
|
|
||||||
|
store_db = Cafe24Store(dsn)
|
||||||
|
api = build_cafe24_api(store_db)
|
||||||
|
interval = 0 if args.once else max(0, int(args.loop))
|
||||||
|
logger.info("카페24 예약 worker 시작 (간격 %s초)", interval or "단발")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
count = process_once(store_db, api)
|
||||||
|
if count:
|
||||||
|
logger.info("예약 %s건 처리", count)
|
||||||
|
except Exception: # noqa: BLE001 — DB 순간 장애로 죽지 않게
|
||||||
|
logger.exception("예약 처리 루프에서 오류 — 다음 주기에 다시 시도")
|
||||||
|
if not interval:
|
||||||
|
return 0
|
||||||
|
time.sleep(interval)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("종료 요청 — worker 를 멈춥니다.")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
store_db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import calendar as _calendar
|
import calendar as _calendar
|
||||||
import json
|
import json
|
||||||
|
from fractions import Fraction
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.timezone import today_kst
|
from app.timezone import today_kst
|
||||||
@@ -568,6 +569,165 @@ async def box_rule_delete(
|
|||||||
return RedirectResponse(url="/cupang/box-rules", status_code=303)
|
return RedirectResponse(url="/cupang/box-rules", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 박스 계산기 — 제품명 + 수량 → 박스 수 / 남은 낱개
|
||||||
|
# 저장하지 않는 계산 전용 화면. 규칙은 cupang_box_rules 를 그대로 사용한다.
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
@router.get("/box-calc", response_class=HTMLResponse)
|
||||||
|
async def box_calc_page(request: Request) -> HTMLResponse:
|
||||||
|
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
guard = _guard(request)
|
||||||
|
if not isinstance(guard, tuple):
|
||||||
|
return guard
|
||||||
|
store, user = guard
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"cupang/box_calc.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"nav_items": build_erp_nav(user, active="cupang"),
|
||||||
|
"page_title": "쿠팡 밀크런 — 박스 계산",
|
||||||
|
"page_subtitle": "제품명과 수량을 넣으면 박스 수와 남은 낱개를 계산합니다.",
|
||||||
|
"box_rules": store.list_box_rules(),
|
||||||
|
# 센터 선택 드롭다운은 가나다순 (한글 음절은 코드포인트 순 = 가나다순)
|
||||||
|
"centers": sorted(store.list_centers(), key=lambda c: (c.get("name") or "")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/box-calc")
|
||||||
|
async def box_calc_api(
|
||||||
|
request: Request,
|
||||||
|
payload: dict[str, Any] = Body(...),
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""[{product_code, quantity}] → 제품별 박스 계산 + 박스명별 합계.
|
||||||
|
|
||||||
|
클라이언트 계산을 신뢰하지 않고 store.compute_boxes 로 서버에서 계산한다.
|
||||||
|
"""
|
||||||
|
from .store import compute_boxes # noqa: WPS433
|
||||||
|
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
|
||||||
|
raw_items = payload.get("items")
|
||||||
|
if not isinstance(raw_items, list):
|
||||||
|
raise HTTPException(status_code=400, detail="items 는 배열이어야 합니다.")
|
||||||
|
|
||||||
|
rules = {r["product_code"]: r for r in store.list_box_rules()}
|
||||||
|
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
totals: dict[str, dict[str, Any]] = {}
|
||||||
|
for raw in raw_items:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
code = str(raw.get("product_code") or "").strip()
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
qty = int(raw.get("quantity") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
qty = 0
|
||||||
|
qty = max(qty, 0)
|
||||||
|
|
||||||
|
rule = rules.get(code)
|
||||||
|
upb = rule["units_per_box"] if rule else None
|
||||||
|
calc = compute_boxes(qty, upb)
|
||||||
|
box_name = (rule or {}).get("box_name") or ""
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"product_code": code,
|
||||||
|
"product_name": (rule or {}).get("product_name_snapshot") or code,
|
||||||
|
"box_name": box_name,
|
||||||
|
"units_per_box": calc["units_per_box"],
|
||||||
|
"quantity": qty,
|
||||||
|
"configured": calc["configured"],
|
||||||
|
"full_boxes": calc["full_boxes"],
|
||||||
|
"remainder_units": calc["remainder_units"],
|
||||||
|
"required_boxes": calc["required_boxes"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if calc["configured"]:
|
||||||
|
agg = totals.setdefault(
|
||||||
|
box_name, {"box_name": box_name, "full_boxes": 0, "required_boxes": 0, "remainder_units": 0}
|
||||||
|
)
|
||||||
|
agg["full_boxes"] += calc["full_boxes"]
|
||||||
|
agg["required_boxes"] += calc["required_boxes"]
|
||||||
|
agg["remainder_units"] += calc["remainder_units"]
|
||||||
|
|
||||||
|
mixes = _pack_leftovers(results)
|
||||||
|
grand_total = sum(r["full_boxes"] or 0 for r in results if r["configured"])
|
||||||
|
grand_total += sum(m["box_count"] for m in mixes)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"results": results,
|
||||||
|
"totals": sorted(totals.values(), key=lambda t: t["box_name"]),
|
||||||
|
"mixes": mixes,
|
||||||
|
"grand_total_boxes": grand_total,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_leftovers(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""제품별 자투리(remainder_units)를 같은 박스명끼리 모아 혼합 박스에 담는다.
|
||||||
|
|
||||||
|
- 한 박스의 용량을 1 로 두고, 제품 1개가 차지하는 부피를 1/units_per_box 로 본다.
|
||||||
|
(예: 쿠팡 2호 = 8개들이 → 1개 = 1/8 박스)
|
||||||
|
- 같은 박스명끼리만 섞는다. 서로 다른 입수량이 섞여도 부피 합으로 정확히 계산된다.
|
||||||
|
- 한 제품의 자투리가 두 박스에 나뉘어 담기는 것은 허용(그래야 박스 수가 최소).
|
||||||
|
- 오차 없이 계산하려고 float 대신 Fraction 을 쓴다.
|
||||||
|
"""
|
||||||
|
groups: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for r in results:
|
||||||
|
if not r["configured"] or not r["remainder_units"]:
|
||||||
|
continue
|
||||||
|
groups.setdefault(r["box_name"], []).append(r)
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for box_name in sorted(groups):
|
||||||
|
# 자투리가 많은 제품부터 담아 박스 안 품목 수를 줄인다.
|
||||||
|
items = sorted(groups[box_name], key=lambda r: -r["remainder_units"])
|
||||||
|
boxes: list[dict[str, Any]] = []
|
||||||
|
cur: list[dict[str, Any]] = []
|
||||||
|
free = Fraction(1)
|
||||||
|
|
||||||
|
for r in items:
|
||||||
|
unit = Fraction(1, int(r["units_per_box"]))
|
||||||
|
left = int(r["remainder_units"])
|
||||||
|
while left > 0:
|
||||||
|
take = min(left, int(free / unit))
|
||||||
|
if take == 0: # 남은 자리 없음 → 새 박스
|
||||||
|
boxes.append({"items": cur, "fill_percent": float(round((1 - free) * 100, 1))})
|
||||||
|
cur, free = [], Fraction(1)
|
||||||
|
continue
|
||||||
|
cur.append(
|
||||||
|
{
|
||||||
|
"product_code": r["product_code"],
|
||||||
|
"product_name": r["product_name"],
|
||||||
|
"quantity": take,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
free -= unit * take
|
||||||
|
left -= take
|
||||||
|
if cur:
|
||||||
|
boxes.append({"items": cur, "fill_percent": float(round((1 - free) * 100, 1))})
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"box_name": box_name,
|
||||||
|
"box_count": len(boxes),
|
||||||
|
"leftover_units": sum(int(r["remainder_units"]) for r in items),
|
||||||
|
"boxes": boxes,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
# 설정 — 제품명 카탈로그 관리 (itemcode_db 에서 등록)
|
# 설정 — 제품명 카탈로그 관리 (itemcode_db 에서 등록)
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -0,0 +1,691 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829g" />{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="cpg">
|
||||||
|
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">박스 입수량 설정</a>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/cupang/centers">입고센터 관리</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not box_rules %}
|
||||||
|
<div class="erp-card cpg-form-card">
|
||||||
|
<p class="erp-muted">
|
||||||
|
등록된 박스 입수량 규칙이 없습니다.
|
||||||
|
<a href="/cupang/box-rules">박스 입수량 설정</a>에서 먼저 제품별 입수량을 등록하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="cpg-calc3">
|
||||||
|
|
||||||
|
<!-- ① 박스 계산 — 제품명 + 수량만 입력 -->
|
||||||
|
<div class="erp-card cpg-form-card cpg-calc-card">
|
||||||
|
<div class="cpg-card-head">
|
||||||
|
<h2>① 박스 계산</h2>
|
||||||
|
<span class="erp-muted">수량 칸에서 Tab = 다음 상품 수량, Enter = 계산</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table cpg-calc-table">
|
||||||
|
<colgroup>
|
||||||
|
<col class="cpg-col-name" />
|
||||||
|
<col class="cpg-col-qty" />
|
||||||
|
<col class="cpg-col-act" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>제품명</th>
|
||||||
|
<th>수량</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="cpg-calc-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-page-actions cpg-calc-actions">
|
||||||
|
<button type="button" class="erp-btn erp-btn-primary" id="cpg-calc-run">계산 / 재계산</button>
|
||||||
|
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-add">+ 제품 추가</button>
|
||||||
|
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-reset">초기화</button>
|
||||||
|
<span class="erp-muted" id="cpg-calc-msg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ② 박스 요약 — 센터에 배분하고 남은 수량이 실시간 반영 -->
|
||||||
|
<div class="erp-card cpg-form-card cpg-calc-sum">
|
||||||
|
<div class="cpg-card-head">
|
||||||
|
<h2>② 박스 요약</h2>
|
||||||
|
<span class="erp-muted">카드를 ③ 센터로 끌어다 놓거나, 카드를 클릭해 담습니다.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cpg-sum-empty" class="erp-muted">왼쪽에서 제품과 수량을 넣고 계산하세요.</div>
|
||||||
|
|
||||||
|
<div id="cpg-sum-body" hidden>
|
||||||
|
<div class="cpg-sum-kpis" id="cpg-sum-kpis"></div>
|
||||||
|
|
||||||
|
<h3 class="cpg-sum-h3">제품별 박스 <span class="erp-muted">잔여 기준</span></h3>
|
||||||
|
<div class="cpg-sum-prods" id="cpg-sum-prods"></div>
|
||||||
|
|
||||||
|
<h3 class="cpg-sum-h3">자투리 혼합 박스 <span class="erp-muted" id="cpg-sum-mix-note"></span></h3>
|
||||||
|
<div class="cpg-sum-boxes" id="cpg-sum-boxes"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ③ 센터 분배 — 선택한 센터만 표시 -->
|
||||||
|
<div class="erp-card cpg-form-card cpg-dist-card">
|
||||||
|
<div class="cpg-card-head">
|
||||||
|
<h2>③ 센터 분배</h2>
|
||||||
|
<span class="erp-muted">담을 센터를 골라 추가하세요.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not centers %}
|
||||||
|
<p class="erp-muted">
|
||||||
|
등록된 입고센터가 없습니다. <a href="/cupang/centers">입고센터 관리</a>에서 먼저 센터를 추가하세요.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="cpg-inline-form cpg-center-pick">
|
||||||
|
<select class="erp-select" id="cpg-center-pick">
|
||||||
|
{% for c in centers %}
|
||||||
|
<option value="{{ c.id }}">{{ c.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="erp-btn erp-btn-primary" id="cpg-center-add">+ 센터 추가</button>
|
||||||
|
</div>
|
||||||
|
<div class="cpg-dist-list" id="cpg-dist-list"></div>
|
||||||
|
<p class="erp-muted" id="cpg-dist-empty">추가한 센터가 여기에 표시됩니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /cpg-calc3 -->
|
||||||
|
|
||||||
|
<!-- 담을 박스 수량 입력 대화상자 -->
|
||||||
|
<div class="cpg-modal" id="cpg-dlg" hidden>
|
||||||
|
<div class="cpg-modal-back" data-dlg-close></div>
|
||||||
|
<div class="cpg-modal-box" role="dialog" aria-modal="true" aria-labelledby="cpg-dlg-title">
|
||||||
|
<h3 id="cpg-dlg-title">센터에 담기</h3>
|
||||||
|
<p class="cpg-dlg-item" id="cpg-dlg-item"></p>
|
||||||
|
<label class="erp-field"><span>센터</span>
|
||||||
|
<select class="erp-select" id="cpg-dlg-center"></select></label>
|
||||||
|
<label class="erp-field"><span>박스 수량</span>
|
||||||
|
<input class="erp-input" type="number" id="cpg-dlg-qty" min="1" step="1" /></label>
|
||||||
|
<p class="erp-muted" id="cpg-dlg-hint"></p>
|
||||||
|
<div class="erp-page-actions cpg-dlg-actions">
|
||||||
|
<button type="button" class="erp-btn erp-btn-primary" id="cpg-dlg-ok">담기</button>
|
||||||
|
<button type="button" class="erp-btn erp-btn-outline" id="cpg-dlg-all">잔여 전부 담기</button>
|
||||||
|
<button type="button" class="erp-btn erp-btn-outline" data-dlg-close>취소</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="application/json" id="cpg-calc-rules">{{ box_rules | tojson }}</script>
|
||||||
|
<script type="application/json" id="cpg-centers">{{ centers | tojson }}</script>
|
||||||
|
<script>
|
||||||
|
// 박스 계산 3열 화면.
|
||||||
|
// ① 제품명·수량 입력 → 서버(POST /cupang/api/box-calc)에서 계산
|
||||||
|
// ② 계산 결과 요약 — 드래그(또는 클릭) 소스
|
||||||
|
// ③ 선택한 센터에만 분배. 담은 수량만큼 ② 의 잔여가 즉시 줄어든다.
|
||||||
|
// 분배 내역은 화면 안에서만 유지된다(DB 저장 없음).
|
||||||
|
(function () {
|
||||||
|
var tbody = document.getElementById("cpg-calc-rows");
|
||||||
|
if (!tbody) return;
|
||||||
|
var runBtn = document.getElementById("cpg-calc-run");
|
||||||
|
var addBtn = document.getElementById("cpg-calc-add");
|
||||||
|
var resetBtn = document.getElementById("cpg-calc-reset");
|
||||||
|
var msg = document.getElementById("cpg-calc-msg");
|
||||||
|
var sumEmpty = document.getElementById("cpg-sum-empty");
|
||||||
|
var sumBody = document.getElementById("cpg-sum-body");
|
||||||
|
var sumKpis = document.getElementById("cpg-sum-kpis");
|
||||||
|
var sumProds = document.getElementById("cpg-sum-prods");
|
||||||
|
var sumBoxes = document.getElementById("cpg-sum-boxes");
|
||||||
|
var sumMixNote = document.getElementById("cpg-sum-mix-note");
|
||||||
|
var distList = document.getElementById("cpg-dist-list");
|
||||||
|
var distCard = document.querySelector(".cpg-dist-card");
|
||||||
|
var distEmpty = document.getElementById("cpg-dist-empty");
|
||||||
|
var centerPick = document.getElementById("cpg-center-pick");
|
||||||
|
var centerAdd = document.getElementById("cpg-center-add");
|
||||||
|
var dlg = document.getElementById("cpg-dlg");
|
||||||
|
var dlgItem = document.getElementById("cpg-dlg-item");
|
||||||
|
var dlgCenter = document.getElementById("cpg-dlg-center");
|
||||||
|
var dlgQty = document.getElementById("cpg-dlg-qty");
|
||||||
|
var dlgHint = document.getElementById("cpg-dlg-hint");
|
||||||
|
var dlgOk = document.getElementById("cpg-dlg-ok");
|
||||||
|
var dlgAll = document.getElementById("cpg-dlg-all");
|
||||||
|
|
||||||
|
function readJson(id) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
try { return JSON.parse((el && el.textContent) || "[]"); } catch (e) { return []; }
|
||||||
|
}
|
||||||
|
var rules = readJson("cpg-calc-rules");
|
||||||
|
var centers = readJson("cpg-centers");
|
||||||
|
|
||||||
|
function esc(s) { var d = document.createElement("div"); d.textContent = s == null ? "" : s; return d.innerHTML; }
|
||||||
|
|
||||||
|
var optionsHtml = '<option value="">— 제품명 선택 —</option>';
|
||||||
|
rules.forEach(function (r) {
|
||||||
|
optionsHtml += '<option value="' + esc(r.product_code) + '">' +
|
||||||
|
esc(r.product_name_snapshot || r.product_code) + "</option>";
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 상태 ─────────────────────────────────────────
|
||||||
|
var calc = { results: [], mixes: [] }; // 서버 계산 결과
|
||||||
|
var units = {}; // key -> 박스 1개당 상품 수
|
||||||
|
var labels = {}; // key -> 표시 이름
|
||||||
|
var openCenters = []; // ③ 에 추가한 센터 id (문자열)
|
||||||
|
var alloc = {}; // centerId -> [{id, key, count}]
|
||||||
|
var seq = 0;
|
||||||
|
var pending = null; // 대화상자 대상 {key, centerId}
|
||||||
|
|
||||||
|
function prodKey(code) { return "p:" + code; }
|
||||||
|
function mixKey(boxName, idx) { return "m:" + boxName + "#" + idx; }
|
||||||
|
|
||||||
|
function centerName(cid) {
|
||||||
|
var hit = centers.filter(function (c) { return String(c.id) === String(cid); })[0];
|
||||||
|
return hit ? hit.name : cid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ① 입력 표 ─────────────────────────────────────
|
||||||
|
function addRow(code, qty) {
|
||||||
|
var tr = document.createElement("tr");
|
||||||
|
tr.className = "cpg-calc-row";
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td><select class="erp-select cpg-calc-name">' + optionsHtml + "</select></td>" +
|
||||||
|
'<td><input class="erp-input cpg-calc-qty" type="number" min="0" step="1" value="' + (qty || 0) + '" /></td>' +
|
||||||
|
'<td class="cpg-calc-act"><button type="button" class="erp-btn erp-btn-danger cpg-calc-del" title="행 삭제" aria-label="행 삭제">✕</button></td>';
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
if (code) tr.querySelector(".cpg-calc-name").value = code;
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsArr() {
|
||||||
|
return Array.prototype.slice.call(tbody.querySelectorAll(".cpg-calc-row"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect() {
|
||||||
|
var items = [];
|
||||||
|
rowsArr().forEach(function (tr) {
|
||||||
|
var code = tr.querySelector(".cpg-calc-name").value;
|
||||||
|
if (!code) return;
|
||||||
|
items.push({ product_code: code, quantity: parseInt(tr.querySelector(".cpg-calc-qty").value, 10) || 0 });
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
var items = collect();
|
||||||
|
if (!items.length) { msg.textContent = "제품명을 하나 이상 선택하세요."; return; }
|
||||||
|
msg.textContent = "계산 중…";
|
||||||
|
runBtn.disabled = true;
|
||||||
|
fetch("/cupang/api/box-calc", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ items: items })
|
||||||
|
})
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
calc.results = ((data && data.results) || []).filter(function (r) { return r.configured; });
|
||||||
|
calc.mixes = (data && data.mixes) || [];
|
||||||
|
units = {};
|
||||||
|
labels = {};
|
||||||
|
calc.results.forEach(function (r) {
|
||||||
|
units[prodKey(r.product_code)] = r.units_per_box;
|
||||||
|
labels[prodKey(r.product_code)] = r.product_name;
|
||||||
|
});
|
||||||
|
calc.mixes.forEach(function (m) {
|
||||||
|
m.boxes.forEach(function (b, i) {
|
||||||
|
var n = 0;
|
||||||
|
b.items.forEach(function (it) { n += it.quantity; });
|
||||||
|
units[mixKey(m.box_name, i)] = n;
|
||||||
|
labels[mixKey(m.box_name, i)] = m.box_name + " 혼합 #" + (i + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
var had = Object.keys(alloc).some(function (k) { return alloc[k].length; });
|
||||||
|
alloc = {}; // 계산이 바뀌면 이전 배분은 근거가 사라지므로 비운다.
|
||||||
|
render();
|
||||||
|
msg.textContent = "계산 완료 (" + calc.results.length + "건)" + (had ? " — 센터 분배 초기화됨" : "");
|
||||||
|
})
|
||||||
|
.catch(function () { msg.textContent = "계산 실패 — 새로고침 후 다시 시도하세요."; })
|
||||||
|
.then(function () { runBtn.disabled = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 배분 집계 ─────────────────────────────────────
|
||||||
|
function allocatedFor(key) {
|
||||||
|
var n = 0;
|
||||||
|
Object.keys(alloc).forEach(function (cid) {
|
||||||
|
alloc[cid].forEach(function (a) { if (a.key === key) n += a.count; });
|
||||||
|
});
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalFor(key) {
|
||||||
|
if (key.indexOf("p:") === 0) {
|
||||||
|
var code = key.slice(2);
|
||||||
|
var hit = calc.results.filter(function (r) { return r.product_code === code; })[0];
|
||||||
|
return hit ? hit.full_boxes : 0;
|
||||||
|
}
|
||||||
|
return 1; // 혼합 박스는 1장 = 1박스
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainFor(key) { return Math.max(0, totalFor(key) - allocatedFor(key)); }
|
||||||
|
|
||||||
|
function addAlloc(cid, key, count) {
|
||||||
|
cid = String(cid);
|
||||||
|
var room = remainFor(key);
|
||||||
|
var n = Math.min(Math.max(parseInt(count, 10) || 0, 0), room);
|
||||||
|
if (n <= 0) return 0;
|
||||||
|
alloc[cid] = alloc[cid] || [];
|
||||||
|
var same = alloc[cid].filter(function (a) { return a.key === key; })[0];
|
||||||
|
if (same) { same.count += n; }
|
||||||
|
else { seq += 1; alloc[cid].push({ id: seq, key: key, count: n }); }
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ② 요약 렌더 ───────────────────────────────────
|
||||||
|
function boxSvg(fill, label) {
|
||||||
|
var f = Math.max(0, Math.min(100, fill || 0));
|
||||||
|
var h = 40 * f / 100;
|
||||||
|
return '' +
|
||||||
|
'<svg class="cpg-box-svg" viewBox="0 0 72 64" role="img" aria-label="' + esc(label) + '">' +
|
||||||
|
'<rect class="cpg-box-fill" x="10" y="' + (54 - h) + '" width="52" height="' + h + '" rx="2" />' +
|
||||||
|
'<path class="cpg-box-body" d="M10 20 H62 V54 H10 Z" />' +
|
||||||
|
'<path class="cpg-box-flapL" d="M10 20 L2 8 H30 L36 20 Z" />' +
|
||||||
|
'<path class="cpg-box-flapR" d="M62 20 L70 8 H42 L36 20 Z" />' +
|
||||||
|
'<line class="cpg-box-seam" x1="36" y1="20" x2="36" y2="54" />' +
|
||||||
|
'<text class="cpg-box-label" x="36" y="42" text-anchor="middle">쿠팡박스</text>' +
|
||||||
|
"</svg>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function kpi(label, value, hint) {
|
||||||
|
return '<div class="cpg-kpi"><span class="cpg-kpi-label">' + esc(label) + "</span>" +
|
||||||
|
'<strong class="cpg-kpi-value">' + esc(value) + "</strong>" +
|
||||||
|
'<span class="cpg-kpi-hint">' + esc(hint) + "</span></div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary() {
|
||||||
|
if (!calc.results.length) {
|
||||||
|
sumEmpty.hidden = false;
|
||||||
|
sumBody.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sumEmpty.hidden = true;
|
||||||
|
sumBody.hidden = false;
|
||||||
|
|
||||||
|
var totalProd = 0, remainProd = 0, leftoverUnits = 0;
|
||||||
|
calc.results.forEach(function (r) {
|
||||||
|
totalProd += r.full_boxes;
|
||||||
|
remainProd += remainFor(prodKey(r.product_code));
|
||||||
|
leftoverUnits += r.remainder_units;
|
||||||
|
});
|
||||||
|
var totalMix = 0, remainMix = 0;
|
||||||
|
calc.mixes.forEach(function (m) {
|
||||||
|
m.boxes.forEach(function (b, i) {
|
||||||
|
totalMix += 1;
|
||||||
|
remainMix += remainFor(mixKey(m.box_name, i));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
var assigned = (totalProd + totalMix) - (remainProd + remainMix);
|
||||||
|
|
||||||
|
sumKpis.innerHTML =
|
||||||
|
kpi("총 박스", (totalProd + totalMix) + "박스", "제품별 " + totalProd + " + 혼합 " + totalMix) +
|
||||||
|
kpi("센터 배분", assigned + "박스", "③ 에 담긴 수량") +
|
||||||
|
kpi("미배분 잔여", (remainProd + remainMix) + "박스", "자투리 " + leftoverUnits + "개 포함");
|
||||||
|
|
||||||
|
var ph = "";
|
||||||
|
calc.results.forEach(function (r) {
|
||||||
|
var key = prodKey(r.product_code);
|
||||||
|
var remain = remainFor(key);
|
||||||
|
var used = r.full_boxes - remain;
|
||||||
|
ph += '<div class="cpg-sum-prod' + (remain ? " is-draggable" : " is-done") + '"' +
|
||||||
|
' data-key="' + esc(key) + '">' +
|
||||||
|
'<div class="cpg-sum-prod-head">' +
|
||||||
|
'<span class="cpg-sum-prod-name">' + esc(r.product_name) + "</span>" +
|
||||||
|
'<span class="erp-badge erp-badge-neutral">' + esc(r.box_name) + "</span>" +
|
||||||
|
"</div>" +
|
||||||
|
'<div class="cpg-sum-prod-nums">' +
|
||||||
|
"<strong>" + remain + "박스</strong>" +
|
||||||
|
'<span class="erp-muted">' + (remain * r.units_per_box) + "개 · 총 " + r.full_boxes + "박스" +
|
||||||
|
(used ? " · 배분 " + used : "") + "</span>" +
|
||||||
|
(r.remainder_units
|
||||||
|
? '<span class="cpg-sum-left">자투리 ' + r.remainder_units + "개</span>"
|
||||||
|
: '<span class="cpg-sum-exact">딱 맞음</span>') +
|
||||||
|
"</div>" +
|
||||||
|
"</div>";
|
||||||
|
});
|
||||||
|
sumProds.innerHTML = ph;
|
||||||
|
|
||||||
|
if (!calc.mixes.length) {
|
||||||
|
sumMixNote.textContent = "— 자투리 없음";
|
||||||
|
sumBoxes.innerHTML = '<p class="erp-muted">남는 낱개가 없습니다.</p>';
|
||||||
|
} else {
|
||||||
|
sumMixNote.textContent = "— 1장 = 1박스";
|
||||||
|
var bh = "", n = 0;
|
||||||
|
calc.mixes.forEach(function (m) {
|
||||||
|
m.boxes.forEach(function (b, i) {
|
||||||
|
var key = mixKey(m.box_name, i);
|
||||||
|
var remain = remainFor(key);
|
||||||
|
var items = b.items.map(function (it) {
|
||||||
|
return "<li><span>" + esc(it.product_name) + "</span><b>" + it.quantity + "개</b></li>";
|
||||||
|
}).join("");
|
||||||
|
bh += '<div class="cpg-box-card' + (remain ? " is-draggable" : " is-done") + '"' +
|
||||||
|
' data-key="' + esc(key) + '"' +
|
||||||
|
' style="animation-delay:' + (n * 60) + 'ms">' +
|
||||||
|
'<div class="cpg-box-art">' + boxSvg(b.fill_percent, m.box_name) + "</div>" +
|
||||||
|
'<div class="cpg-box-info">' +
|
||||||
|
'<div class="cpg-box-title">' + esc(m.box_name) + " 혼합 #" + (i + 1) +
|
||||||
|
(remain ? "" : ' <span class="erp-badge erp-badge-success">배분됨</span>') + "</div>" +
|
||||||
|
'<ul class="cpg-box-items">' + items + "</ul>" +
|
||||||
|
'<div class="cpg-box-bar"><span style="width:' + b.fill_percent + '%"></span></div>' +
|
||||||
|
'<div class="cpg-box-fillnum">' + b.fill_percent + "% 채움 · " + units[key] + "개</div>" +
|
||||||
|
"</div>" +
|
||||||
|
"</div>";
|
||||||
|
n += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
sumBoxes.innerHTML = bh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ③ 센터 분배 렌더 ──────────────────────────────
|
||||||
|
function renderDist() {
|
||||||
|
if (!distList) return;
|
||||||
|
if (distEmpty) distEmpty.hidden = openCenters.length > 0;
|
||||||
|
|
||||||
|
var html = "";
|
||||||
|
openCenters.forEach(function (cid) {
|
||||||
|
var rows = alloc[cid] || [];
|
||||||
|
var boxes = 0, pieces = 0, items = "";
|
||||||
|
rows.forEach(function (a) {
|
||||||
|
var per = units[a.key] || 0;
|
||||||
|
boxes += a.count;
|
||||||
|
pieces += a.count * per;
|
||||||
|
items += '<li class="cpg-dist-item" data-entry="' + a.id + '">' +
|
||||||
|
'<span class="cpg-dist-name">' + esc(labels[a.key] || a.key) + "</span>" +
|
||||||
|
'<input class="erp-input cpg-dist-qty" type="number" min="1" step="1" value="' + a.count + '" />' +
|
||||||
|
'<span class="cpg-dist-unit">박스 · ' + (a.count * per) + "개</span>" +
|
||||||
|
'<button type="button" class="erp-btn erp-btn-danger cpg-dist-del" title="빼기" aria-label="빼기">✕</button>' +
|
||||||
|
"</li>";
|
||||||
|
});
|
||||||
|
html += '<div class="cpg-dist-center' + (rows.length ? " has-items" : "") + '" data-drop-for="' + esc(cid) + '">' +
|
||||||
|
'<div class="cpg-dist-head">' +
|
||||||
|
"<strong>" + esc(centerName(cid)) + "</strong>" +
|
||||||
|
'<span class="cpg-dist-sum">' +
|
||||||
|
'<span class="erp-badge erp-badge-neutral">' + boxes + "박스</span>" +
|
||||||
|
'<span class="erp-badge erp-badge-neutral">' + pieces + "개</span>" +
|
||||||
|
'<button type="button" class="erp-btn erp-btn-outline cpg-dist-close" title="센터 빼기" aria-label="센터 빼기">✕</button>' +
|
||||||
|
"</span>" +
|
||||||
|
"</div>" +
|
||||||
|
(rows.length
|
||||||
|
? '<ul class="cpg-dist-items">' + items + "</ul>"
|
||||||
|
: '<p class="cpg-dist-hint">여기로 박스를 끌어다 놓기</p>') +
|
||||||
|
"</div>";
|
||||||
|
});
|
||||||
|
distList.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() { renderSummary(); renderDist(); }
|
||||||
|
|
||||||
|
// ── 수량 대화상자 ─────────────────────────────────
|
||||||
|
function openDialog(key, centerId) {
|
||||||
|
if (!openCenters.length) { msg.textContent = "먼저 ③ 에서 센터를 추가하세요."; return; }
|
||||||
|
var remain = remainFor(key);
|
||||||
|
if (remain <= 0) { msg.textContent = "남은 박스가 없습니다."; return; }
|
||||||
|
|
||||||
|
pending = { key: key };
|
||||||
|
dlgItem.textContent = (labels[key] || key) + " — 잔여 " + remain + "박스 (" + (remain * (units[key] || 0)) + "개)";
|
||||||
|
dlgCenter.innerHTML = openCenters.map(function (cid) {
|
||||||
|
return '<option value="' + esc(cid) + '"' + (String(cid) === String(centerId) ? " selected" : "") + ">" +
|
||||||
|
esc(centerName(cid)) + "</option>";
|
||||||
|
}).join("");
|
||||||
|
dlgQty.max = remain;
|
||||||
|
dlgQty.value = key.indexOf("m:") === 0 ? 1 : remain;
|
||||||
|
dlgHint.textContent = "1박스 = " + (units[key] || 0) + "개 · 최대 " + remain + "박스";
|
||||||
|
dlg.hidden = false;
|
||||||
|
dlgQty.focus();
|
||||||
|
dlgQty.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() { dlg.hidden = true; pending = null; }
|
||||||
|
|
||||||
|
function commitDialog(all) {
|
||||||
|
if (!pending) return;
|
||||||
|
var key = pending.key;
|
||||||
|
var cid = dlgCenter.value;
|
||||||
|
var want = all ? remainFor(key) : parseInt(dlgQty.value, 10);
|
||||||
|
var got = addAlloc(cid, key, want);
|
||||||
|
closeDialog();
|
||||||
|
render();
|
||||||
|
msg.textContent = got
|
||||||
|
? centerName(cid) + " ← " + (labels[key] || key) + " " + got + "박스"
|
||||||
|
: "담을 수량이 없습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
dlgOk.addEventListener("click", function () { commitDialog(false); });
|
||||||
|
dlgAll.addEventListener("click", function () { commitDialog(true); });
|
||||||
|
dlg.addEventListener("click", function (e) {
|
||||||
|
if (e.target.hasAttribute && e.target.hasAttribute("data-dlg-close")) closeDialog();
|
||||||
|
});
|
||||||
|
dlgQty.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); commitDialog(false); }
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && !dlg.hidden) closeDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 드래그 (마우스 이벤트 기반) ───────────────────
|
||||||
|
// HTML5 네이티브 DnD(draggable + dragstart)는 브라우저에 따라 드래그가
|
||||||
|
// 아예 시작되지 않는다(웨일에서 미리보기조차 안 생김) → 직접 구현한다.
|
||||||
|
// · mousedown 후 4px 이상 움직이면 드래그 시작, 카드 복제본(고스트)이 커서를 따라온다
|
||||||
|
// · 커서 아래 요소를 elementFromPoint 로 찾아 센터 패널을 강조
|
||||||
|
// · mouseup 위치의 센터에 놓으면 수량 대화상자가 열린다
|
||||||
|
// · 드래그 없이 클릭하면 첫 번째 센터를 기본값으로 대화상자가 열린다
|
||||||
|
var drag = null; // {key, card, ghost, startX, startY, moved}
|
||||||
|
var suppressClickOn = null; // 드래그로 끝난 카드의 잔여 click 1회 무시
|
||||||
|
var DRAG_THRESHOLD = 4;
|
||||||
|
|
||||||
|
function clearOver() {
|
||||||
|
if (!distList) return;
|
||||||
|
Array.prototype.forEach.call(distList.querySelectorAll(".is-over"), function (el) {
|
||||||
|
el.classList.remove("is-over");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoneAt(x, y) {
|
||||||
|
if (!distList) return null;
|
||||||
|
var el = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
|
||||||
|
var zone = el && el.closest ? el.closest("[data-drop-for]") : null;
|
||||||
|
if (zone) return zone;
|
||||||
|
// ③ 영역 안(패널 사이 여백 포함)에서 놓았고 센터가 하나뿐이면 그 센터로.
|
||||||
|
if (el && el.closest && el.closest(".cpg-dist-card") && openCenters.length === 1) {
|
||||||
|
return distList.querySelector("[data-drop-for]");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveGhost(x, y) {
|
||||||
|
if (!drag || !drag.ghost) return;
|
||||||
|
drag.ghost.style.left = (x + 12) + "px";
|
||||||
|
drag.ghost.style.top = (y + 12) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(card, x, y) {
|
||||||
|
var ghost = card.cloneNode(true);
|
||||||
|
ghost.className = card.className + " cpg-drag-ghost";
|
||||||
|
ghost.removeAttribute("style");
|
||||||
|
ghost.style.width = card.offsetWidth ? card.offsetWidth + "px" : "240px";
|
||||||
|
document.body.appendChild(ghost);
|
||||||
|
drag.ghost = ghost;
|
||||||
|
drag.moved = true;
|
||||||
|
card.classList.add("is-dragging");
|
||||||
|
document.body.classList.add("cpg-dragging");
|
||||||
|
if (distCard) distCard.classList.add("is-drop-ready");
|
||||||
|
moveGhost(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag(commit, x, y, upTarget) {
|
||||||
|
if (!drag) return;
|
||||||
|
var key = drag.key, moved = drag.moved, card = drag.card;
|
||||||
|
if (drag.ghost && drag.ghost.parentNode) drag.ghost.parentNode.removeChild(drag.ghost);
|
||||||
|
if (drag.card) drag.card.classList.remove("is-dragging");
|
||||||
|
document.body.classList.remove("cpg-dragging");
|
||||||
|
if (distCard) distCard.classList.remove("is-drop-ready");
|
||||||
|
clearOver();
|
||||||
|
drag = null;
|
||||||
|
if (!moved) return; // 움직이지 않았으면 클릭으로 처리
|
||||||
|
// 카드 위에서 손을 뗀 경우에만 뒤따르는 click 을 1회 무시한다.
|
||||||
|
suppressClickOn = (card && upTarget && card.contains(upTarget)) ? card : null;
|
||||||
|
if (!commit) return;
|
||||||
|
var zone = zoneAt(x, y);
|
||||||
|
if (!zone) { msg.textContent = "센터 위에 놓아야 담깁니다."; return; }
|
||||||
|
openDialog(key, zone.getAttribute("data-drop-for"));
|
||||||
|
}
|
||||||
|
|
||||||
|
sumBody.addEventListener("mousedown", function (e) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
var card = e.target.closest("[data-key]");
|
||||||
|
if (!card || card.classList.contains("is-done")) return;
|
||||||
|
drag = { key: card.getAttribute("data-key"), card: card, startX: e.clientX, startY: e.clientY, moved: false, ghost: null };
|
||||||
|
e.preventDefault(); // 텍스트 선택 방지
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", function (e) {
|
||||||
|
if (!drag) return;
|
||||||
|
if (!drag.moved) {
|
||||||
|
if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD &&
|
||||||
|
Math.abs(e.clientY - drag.startY) < DRAG_THRESHOLD) return;
|
||||||
|
startDrag(drag.card, e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
moveGhost(e.clientX, e.clientY);
|
||||||
|
var zone = zoneAt(e.clientX, e.clientY);
|
||||||
|
clearOver();
|
||||||
|
if (zone) zone.classList.add("is-over");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mouseup", function (e) {
|
||||||
|
if (!drag) return;
|
||||||
|
endDrag(true, e.clientX, e.clientY, e.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && drag) endDrag(false, 0, 0, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 드래그하지 않고 클릭한 경우 — 첫 센터를 기본값으로 대화상자를 연다.
|
||||||
|
// (드래그를 끝낸 카드에서 브라우저가 한 번 더 쏘는 click 은 1회만 무시)
|
||||||
|
sumBody.addEventListener("click", function (e) {
|
||||||
|
var card = e.target.closest("[data-key]");
|
||||||
|
if (!card) return;
|
||||||
|
if (card === suppressClickOn) { suppressClickOn = null; return; }
|
||||||
|
openDialog(card.getAttribute("data-key"), openCenters[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (distList) {
|
||||||
|
distList.addEventListener("input", function (e) {
|
||||||
|
if (!e.target.classList.contains("cpg-dist-qty")) return;
|
||||||
|
var li = e.target.closest(".cpg-dist-item");
|
||||||
|
var cid = e.target.closest("[data-drop-for]").getAttribute("data-drop-for");
|
||||||
|
var entry = (alloc[cid] || []).filter(function (a) { return String(a.id) === li.getAttribute("data-entry"); })[0];
|
||||||
|
if (!entry) return;
|
||||||
|
var want = parseInt(e.target.value, 10);
|
||||||
|
if (isNaN(want) || want < 1) want = 1;
|
||||||
|
var max = entry.count + remainFor(entry.key);
|
||||||
|
if (want > max) { want = max; e.target.value = max; }
|
||||||
|
entry.count = want;
|
||||||
|
li.querySelector(".cpg-dist-unit").textContent = "박스 · " + (want * (units[entry.key] || 0)) + "개";
|
||||||
|
renderSummary();
|
||||||
|
updateCenterTotals(cid);
|
||||||
|
});
|
||||||
|
|
||||||
|
distList.addEventListener("click", function (e) {
|
||||||
|
var zone = e.target.closest("[data-drop-for]");
|
||||||
|
if (!zone) return;
|
||||||
|
var cid = zone.getAttribute("data-drop-for");
|
||||||
|
if (e.target.closest(".cpg-dist-close")) { // 센터 자체를 목록에서 뺀다
|
||||||
|
openCenters = openCenters.filter(function (x) { return x !== cid; });
|
||||||
|
delete alloc[cid];
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.target.closest(".cpg-dist-del")) { // 담은 항목 제거 → 잔여로 복귀
|
||||||
|
var li = e.target.closest(".cpg-dist-item");
|
||||||
|
alloc[cid] = (alloc[cid] || []).filter(function (a) { return String(a.id) !== li.getAttribute("data-entry"); });
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCenterTotals(cid) {
|
||||||
|
var zone = distList.querySelector('[data-drop-for="' + cid + '"]');
|
||||||
|
if (!zone) return;
|
||||||
|
var boxes = 0, pieces = 0;
|
||||||
|
(alloc[cid] || []).forEach(function (a) {
|
||||||
|
boxes += a.count;
|
||||||
|
pieces += a.count * (units[a.key] || 0);
|
||||||
|
});
|
||||||
|
var badges = zone.querySelectorAll(".cpg-dist-sum .erp-badge");
|
||||||
|
if (badges[0]) badges[0].textContent = boxes + "박스";
|
||||||
|
if (badges[1]) badges[1].textContent = pieces + "개";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (centerAdd) {
|
||||||
|
centerAdd.addEventListener("click", function () {
|
||||||
|
var cid = centerPick.value;
|
||||||
|
if (!cid) return;
|
||||||
|
if (openCenters.indexOf(cid) >= 0) { msg.textContent = "이미 추가된 센터입니다."; return; }
|
||||||
|
openCenters.push(cid);
|
||||||
|
renderDist();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 입력 표 이벤트 ────────────────────────────────
|
||||||
|
tbody.addEventListener("click", function (e) {
|
||||||
|
if (!e.target.closest(".cpg-calc-del")) return;
|
||||||
|
if (rowsArr().length <= 1) addRow("", 0);
|
||||||
|
e.target.closest("tr").remove();
|
||||||
|
msg.textContent = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.addEventListener("input", function (e) {
|
||||||
|
if (e.target.classList.contains("cpg-calc-qty")) msg.textContent = "수량 변경됨 — 재계산하세요.";
|
||||||
|
});
|
||||||
|
tbody.addEventListener("change", function (e) {
|
||||||
|
if (e.target.classList.contains("cpg-calc-name")) msg.textContent = "제품 변경됨 — 재계산하세요.";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 수량 칸: Tab = 다음 행 수량(마지막 행이면 새 행 추가), Enter = 계산
|
||||||
|
tbody.addEventListener("keydown", function (e) {
|
||||||
|
if (!e.target.classList.contains("cpg-calc-qty")) return;
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); run(); return; }
|
||||||
|
if (e.key !== "Tab" || e.shiftKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var rows = rowsArr();
|
||||||
|
var idx = rows.indexOf(e.target.closest("tr"));
|
||||||
|
var next = rows[idx + 1] || addRow("", 0);
|
||||||
|
var input = next.querySelector(".cpg-calc-qty");
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
});
|
||||||
|
|
||||||
|
runBtn.addEventListener("click", run);
|
||||||
|
addBtn.addEventListener("click", function () {
|
||||||
|
addRow("", 0).querySelector(".cpg-calc-name").focus();
|
||||||
|
});
|
||||||
|
resetBtn.addEventListener("click", function () {
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
addRow("", 0);
|
||||||
|
calc = { results: [], mixes: [] };
|
||||||
|
alloc = {};
|
||||||
|
render();
|
||||||
|
msg.textContent = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
addRow("", 0);
|
||||||
|
render();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829g" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829g" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829g" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829g" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829g" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<a class="erp-btn erp-btn-outline" href="/cupang/products">제품명 설정</a>
|
<a class="erp-btn erp-btn-outline" href="/cupang/products">제품명 설정</a>
|
||||||
<a class="erp-btn erp-btn-outline" href="/cupang/centers">입고센터 관리</a>
|
<a class="erp-btn erp-btn-outline" href="/cupang/centers">입고센터 관리</a>
|
||||||
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">박스 입수량 설정</a>
|
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">박스 입수량 설정</a>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/cupang/box-calc">박스 계산</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="cpg-actions-spacer"></div>
|
<div class="cpg-actions-spacer"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829g" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
@@ -21,6 +21,21 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 수기 추가: itemcode_db 에 없는 상품도 직접 등록 -->
|
||||||
|
<form method="post" action="/cupang/products" class="cpg-prod-manual">
|
||||||
|
<div class="cpg-prod-manual-head">
|
||||||
|
<strong>수기 추가</strong>
|
||||||
|
<span class="erp-muted">아래 목록에 없는 상품을 직접 등록합니다. 같은 제품코드는 덮어씁니다.</span>
|
||||||
|
</div>
|
||||||
|
<div class="cpg-prod-manual-row">
|
||||||
|
<label class="erp-field"><span>제품명 *</span>
|
||||||
|
<input class="erp-input" type="text" name="product_name" required placeholder="예: 미라네 12호 세트" /></label>
|
||||||
|
<label class="erp-field"><span>제품코드 *</span>
|
||||||
|
<input class="erp-input" type="text" name="product_code" required placeholder="예: MS-1012" /></label>
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary">추가</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
{% if search_enabled %}
|
{% if search_enabled %}
|
||||||
<div class="cpg-inline-form" style="margin-bottom:10px;">
|
<div class="cpg-inline-form" style="margin-bottom:10px;">
|
||||||
<input class="erp-input" type="text" id="cpg-prod-q" placeholder="이름/코드 필터" style="min-width:200px" />
|
<input class="erp-input" type="text" id="cpg-prod-q" placeholder="이름/코드 필터" style="min-width:200px" />
|
||||||
@@ -38,10 +53,17 @@
|
|||||||
<span class="erp-muted">폼의 제품명 드롭다운에 노출</span></div>
|
<span class="erp-muted">폼의 제품명 드롭다운에 노출</span></div>
|
||||||
<div class="erp-table-wrap cpg-reg-scroll">
|
<div class="erp-table-wrap cpg-reg-scroll">
|
||||||
<table class="erp-table">
|
<table class="erp-table">
|
||||||
<thead><tr><th>제품명</th><th>제품코드</th><th>상태</th><th>동작</th></tr></thead>
|
<thead>
|
||||||
<tbody>
|
<tr>
|
||||||
|
<th><button type="button" class="cpg-sort" data-sort-key="name">제품명<span class="cpg-sort-ind" aria-hidden="true"></span></button></th>
|
||||||
|
<th><button type="button" class="cpg-sort" data-sort-key="code">제품코드<span class="cpg-sort-ind" aria-hidden="true"></span></button></th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>동작</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="cpg-prod-tbody">
|
||||||
{% for p in products %}
|
{% for p in products %}
|
||||||
<tr {% if not p.active %}style="opacity:.55"{% endif %}>
|
<tr data-name="{{ p.product_name }}" data-code="{{ p.product_code }}" {% if not p.active %}style="opacity:.55"{% endif %}>
|
||||||
<td>{{ p.product_name }}</td>
|
<td>{{ p.product_name }}</td>
|
||||||
<td>{{ p.product_code }}</td>
|
<td>{{ p.product_code }}</td>
|
||||||
<td>{% if p.active %}<span class="erp-badge erp-badge-success">활성</span>{% else %}<span class="erp-badge erp-badge-neutral">비활성</span>{% endif %}</td>
|
<td>{% if p.active %}<span class="erp-badge erp-badge-success">활성</span>{% else %}<span class="erp-badge erp-badge-neutral">비활성</span>{% endif %}</td>
|
||||||
@@ -67,7 +89,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if not products %}
|
{% if not products %}
|
||||||
<tr><td colspan="4" class="erp-muted">등록된 제품명이 없습니다. 왼쪽에서 선택해 등록하세요.</td></tr>
|
<tr class="cpg-no-sort"><td colspan="4" class="erp-muted">등록된 제품명이 없습니다. 왼쪽에서 선택해 등록하세요.</td></tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -77,6 +99,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 등록된 제품명 표 — 제품명/제품코드 오름차순·내림차순 정렬 (클라이언트).
|
||||||
|
(function () {
|
||||||
|
var tbody = document.getElementById("cpg-prod-tbody");
|
||||||
|
if (!tbody) return;
|
||||||
|
var buttons = Array.prototype.slice.call(document.querySelectorAll(".cpg-sort"));
|
||||||
|
var state = { key: null, dir: 1 };
|
||||||
|
var collator = new Intl.Collator("ko", { numeric: true, sensitivity: "base" });
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
var rows = Array.prototype.slice.call(tbody.querySelectorAll("tr:not(.cpg-no-sort)"));
|
||||||
|
if (rows.length < 2) return;
|
||||||
|
rows.sort(function (a, b) {
|
||||||
|
var av = a.getAttribute("data-" + state.key) || "";
|
||||||
|
var bv = b.getAttribute("data-" + state.key) || "";
|
||||||
|
return collator.compare(av, bv) * state.dir;
|
||||||
|
});
|
||||||
|
rows.forEach(function (r) { tbody.appendChild(r); });
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons.forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var key = btn.getAttribute("data-sort-key");
|
||||||
|
if (state.key === key) { state.dir = -state.dir; }
|
||||||
|
else { state.key = key; state.dir = 1; }
|
||||||
|
buttons.forEach(function (b) {
|
||||||
|
var on = b === btn;
|
||||||
|
b.classList.toggle("is-asc", on && state.dir === 1);
|
||||||
|
b.classList.toggle("is-desc", on && state.dir === -1);
|
||||||
|
b.closest("th").setAttribute("aria-sort", on ? (state.dir === 1 ? "ascending" : "descending") : "none");
|
||||||
|
});
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
{% if search_enabled %}
|
{% if search_enabled %}
|
||||||
<script type="application/json" id="cpg-registered">{{ registered_codes | tojson }}</script>
|
<script type="application/json" id="cpg-registered">{{ registered_codes | tojson }}</script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -0,0 +1,805 @@
|
|||||||
|
/* 카페24 상품관리 모듈 전용 스타일.
|
||||||
|
전역(erp.css) 은 건드리지 않는다. 클래스 접두사: cf24-
|
||||||
|
색상은 erp.css 의 :root 토큰을 재사용한다. */
|
||||||
|
|
||||||
|
/* .erp-page 는 flex column + overflow-y:auto 다. 자식의 flex-shrink 기본값이
|
||||||
|
1 이라 내용이 화면보다 길면 카드가 눌려 잘리고(스크롤도 안 생긴다),
|
||||||
|
시스템 화면의 [카페24 연결] 버튼처럼 카드 아래쪽이 사라진다.
|
||||||
|
축소를 막아 넘친 만큼 .erp-page 가 스크롤하게 한다.
|
||||||
|
이 파일은 카페24 템플릿에서만 로드되므로 다른 모듈에는 영향이 없다. */
|
||||||
|
.erp-page > * {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다.
|
||||||
|
box-sizing 을 함께 바꿔야 한다: .erp-page 는 width:100% + padding:24px 라서
|
||||||
|
max-width 를 풀면 padding 이 폭에 더해져 문서 전체에 가로 스크롤이 생긴다. */
|
||||||
|
.erp-page {
|
||||||
|
max-width: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-card {
|
||||||
|
margin-bottom: var(--sp-16, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════
|
||||||
|
좌우 2분할: 왼쪽 목록(좁게) | 오른쪽 상세페이지 편집(넓게)
|
||||||
|
각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다.
|
||||||
|
════════════════════════════════════════════════════════════ */
|
||||||
|
.cf24-split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 550px minmax(0, 1fr);
|
||||||
|
gap: var(--sp-12, 12px);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .erp-page 는 flex column 이다. 위 flex-shrink:0 규칙의 예외로, 분할 영역만은
|
||||||
|
남은 높이를 모두 차지하고 안에서 스크롤되게 한다(휴가/쿠팡 모듈과 같은 방식). */
|
||||||
|
.erp-page > .cf24-split {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-pane {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: var(--sp-12, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-pane-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
overflow: hidden; /* 표만 스크롤 — 검색/필터는 고정 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-pane-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
padding: var(--sp-16, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.cf24-split {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.cf24-pane {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.cf24-pane-list {
|
||||||
|
max-height: 45vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 왼쪽: 검색·필터 ── */
|
||||||
|
.cf24-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-checks {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-12, 12px);
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-checks label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-list-count {
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 왼쪽: 목록 표 (좁게) ── */
|
||||||
|
.cf24-list-scroll {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-list-table {
|
||||||
|
font-size: 12px;
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-list-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 6px 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-list-table tbody td {
|
||||||
|
padding: 6px 4px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 목록 폭 550px 기준. 고정폭을 뺀 나머지(약 290px)가 상품명 몫이다.
|
||||||
|
진열/판매 칸은 제목(2글자) + 정렬 화살표가 들어갈 만큼만. */
|
||||||
|
.cf24-col-no { width: 40px; text-align: right; color: var(--color-midtone-gray, #737373); }
|
||||||
|
.cf24-col-flag { width: 40px; text-align: center; }
|
||||||
|
.cf24-col-date { width: 78px; white-space: nowrap; color: var(--color-midtone-gray, #737373); }
|
||||||
|
.cf24-col-name { word-break: break-word; }
|
||||||
|
|
||||||
|
/* 긴 상품명은 2줄까지만 — 행 높이를 고르게 유지해 목록을 훑기 쉽게 한다.
|
||||||
|
전체 이름은 title 툴팁과 오른쪽 편집기 제목에서 확인한다. */
|
||||||
|
.cf24-col-name a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-row:hover {
|
||||||
|
background: var(--color-ghost-gray, #f2f2f2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-row.is-active {
|
||||||
|
background: var(--color-rich-black, #0a0a0a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 선택된 행은 마우스를 올려도 그대로 둔다. erp-shell.css 의
|
||||||
|
`.erp-table tbody tr:hover` 가 특이도가 더 높아 검은 배경을 덮어쓰므로
|
||||||
|
여기서 더 구체적인 선택자로 되돌린다. */
|
||||||
|
.cf24-list-table tbody tr.cf24-row.is-active:hover {
|
||||||
|
background: var(--color-rich-black, #0a0a0a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-row.is-active td,
|
||||||
|
.cf24-row.is-active .cf24-col-name a {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 진열/판매 표시는 좁은 칸이라 배지 대신 점으로 */
|
||||||
|
.cf24-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-subtle-ash, #e5e5e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-dot-on {
|
||||||
|
background: var(--color-success-green, #10c22b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 제목행 클릭 정렬 */
|
||||||
|
.cf24-sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-sortable::after {
|
||||||
|
content: "↕";
|
||||||
|
opacity: 0.35;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-sortable.is-asc::after { content: "▲"; opacity: 1; }
|
||||||
|
.cf24-sortable.is-desc::after { content: "▼"; opacity: 1; }
|
||||||
|
|
||||||
|
/* ── 오른쪽: 편집기 ── */
|
||||||
|
.cf24-editor-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--sp-12, 12px);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-editor-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-heading, 18px);
|
||||||
|
letter-spacing: -0.45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 상품명 수정 — 평소엔 제목, 연필을 누르면 입력칸으로 바뀐다 */
|
||||||
|
/* 입력칸이 남는 폭을 다 쓰게 한다(min-width:0 이 없으면 긴 이름이 배지를 밀어낸다) */
|
||||||
|
.cf24-name-box {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-name-box .is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-name-edit {
|
||||||
|
margin-left: var(--sp-6, 6px);
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--r-sm, 4px);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
font-size: var(--text-body, 14px);
|
||||||
|
line-height: 1.4;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-name-edit:hover {
|
||||||
|
background: var(--color-ghost-gray, #f2f2f2);
|
||||||
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-name-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-6, 6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-name-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: var(--sp-6, 6px) var(--sp-8, 8px);
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-md, 6px);
|
||||||
|
font-size: var(--text-heading, 18px);
|
||||||
|
letter-spacing: -0.45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-name-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-rich-black, #0a0a0a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-editor-sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-editor-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 편집기 단축키 안내 — 옅은 파랑으로 본문과 구분한다 */
|
||||||
|
.cf24-shortcuts {
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
color: #79a6dd;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 편집 폼과 코드 칸은 **줄어들지 않는다.** 아래 「예약 적용」·「버전 이력」을 펼치면
|
||||||
|
코드 칸이 눌리는 대신 내용이 아래로 밀리고 오른쪽 칸(.cf24-pane-editor)이 스크롤된다.
|
||||||
|
flex: 1 1 auto 였을 때는 펼칠 때마다 편집기가 작아져 작업하던 위치를 잃었다. */
|
||||||
|
.cf24-editor-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-editor-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-editor-bar-right {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-check-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: var(--sp-12, 12px);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-memo {
|
||||||
|
width: 260px;
|
||||||
|
padding: 6px var(--sp-10, 10px);
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-lg, 10px);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-html-main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════
|
||||||
|
문법 강조 편집기
|
||||||
|
투명한 <textarea> 를 색칠된 <pre> 위에 정확히 겹쳐 놓는 방식이다.
|
||||||
|
두 요소의 폰트·줄높이·여백·줄바꿈 규칙이 **완전히 같아야** 글자가 어긋나지
|
||||||
|
않는다. 아래 두 선택자에 붙은 속성을 바꿀 때는 반드시 함께 바꿀 것.
|
||||||
|
외부 라이브러리를 쓰지 않는다(자체 호스팅 원칙 + CDN 의존 제거).
|
||||||
|
════════════════════════════════════════════════════════════ */
|
||||||
|
/* 스크롤은 **textarea 가** 담당하고, 색칠 층과 줄 번호가 transform 으로 따라간다.
|
||||||
|
폭·높이를 계산해 맞추는 방식은 flex 안에서 `width: max-content` 가 기대대로
|
||||||
|
동작하지 않아 실패했다 — 긴 줄이 있으면 textarea 만 내부 스크롤되고 색칠 층은
|
||||||
|
제자리에 남아, 커서 위치와 보이는 글자가 어긋났다(실측 706 vs 686).
|
||||||
|
스크롤 동기화 방식은 크기 계산이 아예 필요 없어 어긋날 여지가 없다. */
|
||||||
|
.cf24-code {
|
||||||
|
position: relative;
|
||||||
|
/* 화면 높이에 맞춰 크되(창 크기에 적응), 무엇을 펼쳐도 이 높이를 유지한다.
|
||||||
|
56vh 는 접힌 상태에서 「예약 적용」·「버전 이력」 요약줄까지 스크롤 없이
|
||||||
|
들어오는 값이다(실측: 62vh 는 51px 넘쳤고 56vh 는 0). */
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 56vh;
|
||||||
|
min-height: 340px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-lg, 10px);
|
||||||
|
background: var(--color-canvas-white, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 줄 번호 — 코드 영역 왼쪽에 고정된 별도 칸(가로 스크롤과 무관) */
|
||||||
|
.cf24-gutter {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 42px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--sp-12, 12px) var(--sp-8, 8px) 0 0;
|
||||||
|
text-align: right;
|
||||||
|
background: var(--color-ghost-gray, #f6f8fa);
|
||||||
|
border-right: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
color: #8c959f;
|
||||||
|
user-select: none;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-gutter-inner {
|
||||||
|
will-change: transform; /* 세로 스크롤을 따라 움직인다 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-code-body {
|
||||||
|
position: absolute;
|
||||||
|
left: 43px; /* 줄 번호 칸 + 경계선 */
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 두 층의 글자가 정확히 겹치려면 아래 속성이 전부 같아야 한다.
|
||||||
|
줄 번호(.cf24-gutter)도 같은 글꼴 지표를 써야 줄이 맞는다. */
|
||||||
|
.cf24-gutter,
|
||||||
|
.cf24-code-hl,
|
||||||
|
.cf24-code-input {
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: var(--font-geist-mono, ui-monospace, "Consolas", monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-code-hl,
|
||||||
|
.cf24-code-input {
|
||||||
|
padding: var(--sp-12, 12px);
|
||||||
|
white-space: pre; /* 줄바꿈하지 않는다 — 줄 번호가 어긋나지 않게 */
|
||||||
|
overflow-wrap: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 색칠 층 — textarea 의 스크롤량만큼 transform 으로 이동한다. */
|
||||||
|
.cf24-code-hl {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* textarea 가 실제 스크롤 주체다. */
|
||||||
|
.cf24-code-input {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
background: transparent;
|
||||||
|
color: transparent;
|
||||||
|
caret-color: var(--color-rich-black, #0a0a0a);
|
||||||
|
resize: none;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-code-input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-code:focus-within {
|
||||||
|
border-color: var(--color-rich-black, #0a0a0a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 선택 영역은 **반투명**이어야 한다.
|
||||||
|
textarea 의 글자는 투명이고 실제 글자는 아래 <pre> 가 그린다. 선택 배경은
|
||||||
|
textarea(위층)가 칠하므로 불투명하면 아래 글자를 덮어 "선택하면 글자가 사라지는"
|
||||||
|
현상이 생긴다. 알파를 줘서 색칠된 글자가 비쳐 보이게 한다. */
|
||||||
|
.cf24-code-input::selection {
|
||||||
|
background: rgba(51, 122, 226, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-code-input::-moz-selection {
|
||||||
|
background: rgba(51, 122, 226, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 토큰 색 — GitHub 라이트 계열 */
|
||||||
|
.cf24-t-tag { color: #116329; } /* 태그 이름 */
|
||||||
|
.cf24-t-attr { color: #953800; } /* 속성 이름 */
|
||||||
|
.cf24-t-val { color: #0a3069; } /* 속성 값 */
|
||||||
|
.cf24-t-pun { color: #57606a; } /* < > / = */
|
||||||
|
.cf24-t-com { color: #6e7781; font-style: italic; } /* 주석 */
|
||||||
|
.cf24-t-doc { color: #6639ba; } /* DOCTYPE 등 선언 */
|
||||||
|
|
||||||
|
.cf24-code-hint {
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-details {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-top: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
padding-top: var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-details > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-compact {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-compact tbody td,
|
||||||
|
.cf24-compact thead th {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-empty-pane {
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--sp-24, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-empty-pane h3 {
|
||||||
|
margin: 0 0 var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
margin-bottom: var(--sp-12, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-card-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-heading, 18px);
|
||||||
|
letter-spacing: -0.45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-muted {
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-err {
|
||||||
|
color: var(--color-callout-red, #c22b10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 상태 배지 */
|
||||||
|
.cf24-badge-ok {
|
||||||
|
background: var(--color-success-green, #10c22b);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-badge-off {
|
||||||
|
background: var(--color-ghost-gray, #f2f2f2);
|
||||||
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 클릭으로 진열/판매를 토글하는 배지. 눌리는 것임을 커서·테두리로 알린다. */
|
||||||
|
.cf24-badge-btn {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-badge-btn:hover:not(:disabled) {
|
||||||
|
filter: brightness(0.94);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-badge-btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 안내/오류 배너 */
|
||||||
|
.cf24-flash {
|
||||||
|
padding: var(--sp-10, 10px) var(--sp-12, 12px);
|
||||||
|
border-radius: var(--r-lg, 10px);
|
||||||
|
margin-bottom: var(--sp-12, 12px);
|
||||||
|
font-size: var(--text-body, 14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-flash-ok {
|
||||||
|
background: #eefaf0;
|
||||||
|
border: 1px solid var(--color-success-green, #10c22b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-flash-err {
|
||||||
|
background: #fdefec;
|
||||||
|
border: 1px solid var(--color-callout-red, #c22b10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 키-값 표 */
|
||||||
|
.cf24-kv th {
|
||||||
|
width: 180px;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
align-items: center;
|
||||||
|
margin-top: var(--sp-12, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-warn {
|
||||||
|
color: var(--color-callout-red, #c22b10);
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 검색 도구모음 / 페이지 이동 */
|
||||||
|
.cf24-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--sp-12, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 한 줄 높이로 고정한다. 세로 flex 안에서 flex-basis 를 주면 그 값이 '높이'로
|
||||||
|
적용돼 입력란이 거대해진다(실제로 그랬다 — flex 방향을 항상 확인할 것). */
|
||||||
|
.cf24-search {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 var(--sp-10, 10px);
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-lg, 10px);
|
||||||
|
font-size: var(--text-body, 14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-pager {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-12, 12px);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--sp-16, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 상세설명 HTML 원문 */
|
||||||
|
.cf24-label {
|
||||||
|
display: block;
|
||||||
|
margin: var(--sp-16, 16px) 0 var(--sp-8, 8px);
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-html {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: var(--sp-12, 12px);
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-lg, 10px);
|
||||||
|
background: var(--color-canvas-white, #fff);
|
||||||
|
font-family: var(--font-geist-mono, ui-monospace, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: auto;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 읽기 전용은 배경으로 구분 — 편집 가능한 칸과 헷갈리지 않게 */
|
||||||
|
.cf24-html[readonly] {
|
||||||
|
background: var(--color-ghost-gray, #f2f2f2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-note {
|
||||||
|
margin: var(--sp-12, 12px) 0 0;
|
||||||
|
padding: var(--sp-10, 10px) var(--sp-12, 12px);
|
||||||
|
border-left: 3px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
background: var(--color-ghost-gray, #f2f2f2);
|
||||||
|
border-radius: var(--r-sm, 6px);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 넓은 로그 표는 카드 안에서만 가로 스크롤 */
|
||||||
|
.cf24-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-scroll code {
|
||||||
|
font-family: var(--font-geist-mono, ui-monospace, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-empty {
|
||||||
|
padding: var(--sp-24, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-empty h3 {
|
||||||
|
margin: 0 0 var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 예약 폼 ── */
|
||||||
|
.cf24-schedule-form {
|
||||||
|
padding-top: var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-schedule-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-schedule-grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-schedule-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 날짜/시간을 별도 input 으로 나눈다 — datetime-local 은 한국어 로캘에서
|
||||||
|
"2026. 08. 14. 오후 07:00" 형태로 길어져 auto-fit minmax(160px, 1fr) 칸에
|
||||||
|
잘렸다(실측). 지금은 표시 텍스트를 우리가 직접 그리므로(아래 .cf24-dt-*) 폭을
|
||||||
|
내용에 맞춰 조절한다. */
|
||||||
|
.cf24-schedule-datetime {
|
||||||
|
grid-column: span 2;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-schedule-datetime-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 날짜/시간 표시 형식을 직접 그리는 오버레이 ──
|
||||||
|
네이티브 date/time input 은 표시 형식을 CSS 로 바꿀 수 없다(브라우저·로캘가
|
||||||
|
강제). 코드 편집기(.cf24-code-input 위 .cf24-code-hl)와 같은 원리로, 투명한
|
||||||
|
네이티브 input 을 우리가 그린 텍스트 위에 완전히 겹쳐 클릭·키보드·달력 팝업은
|
||||||
|
네이티브 그대로 쓰고 보이는 글자만 형식화한다. */
|
||||||
|
.cf24-dt-field {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 날짜 칸에 요일까지 표시("2026년 08월 20일 (목)")하므로 시간 칸보다 넓게 잡는다. */
|
||||||
|
.cf24-dt-field-date {
|
||||||
|
flex: 1.7 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-dt-field-time {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-dt-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-lg, 10px);
|
||||||
|
background: var(--color-canvas-white, #fff);
|
||||||
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
|
/* 12px 는 실측으로 고른 값 — 날짜 칸에 요일까지 들어가도 잘리지 않는 폭·글자크기
|
||||||
|
조합이다. */
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none; /* 클릭은 위에 겹친 네이티브 input 이 받는다 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-dt-native {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
opacity: 0; /* 완전히 투명 — 아래 .cf24-dt-display 가 그대로 비쳐 보인다 */
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-schedule-input {
|
||||||
|
height: 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 var(--sp-8, 8px);
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-lg, 10px);
|
||||||
|
font-size: var(--text-body, 14px);
|
||||||
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
|
background: var(--color-canvas-white, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 상품 다이렉트 주소 ── */
|
||||||
|
.cf24-url-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-url {
|
||||||
|
flex: 1 1 260px;
|
||||||
|
min-width: 0;
|
||||||
|
height: 30px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 var(--sp-10, 10px);
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-lg, 10px);
|
||||||
|
background: var(--color-ghost-gray, #f6f8fa);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
font-family: var(--font-geist-mono, ui-monospace, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
@@ -305,3 +305,393 @@
|
|||||||
.cpg-upb-unit { font-size: 13px; color: var(--color-midtone-gray); }
|
.cpg-upb-unit { font-size: 13px; color: var(--color-midtone-gray); }
|
||||||
/* 메모: 프레임 전체 너비 */
|
/* 메모: 프레임 전체 너비 */
|
||||||
.cpg .cpg-brule-fields .cpg-brule-memo { width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box; }
|
.cpg .cpg-brule-fields .cpg-brule-memo { width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* 등록된 제품명 표 — 정렬 가능한 헤더 */
|
||||||
|
.cpg-sort {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cpg-sort:hover { text-decoration: underline; }
|
||||||
|
.cpg-sort-ind::after {
|
||||||
|
content: "↕";
|
||||||
|
opacity: .35;
|
||||||
|
font-size: .85em;
|
||||||
|
}
|
||||||
|
.cpg-sort.is-asc .cpg-sort-ind::after { content: "▲"; opacity: 1; }
|
||||||
|
.cpg-sort.is-desc .cpg-sort-ind::after { content: "▼"; opacity: 1; }
|
||||||
|
|
||||||
|
/* 박스 계산기 — 가로 스크롤이 생기지 않게 열 폭·여백을 좁게 잡는다. */
|
||||||
|
.cpg-calc-table { table-layout: fixed; width: 100%; }
|
||||||
|
.cpg-calc-table th, .cpg-calc-table td { padding: 8px 6px; }
|
||||||
|
.cpg-calc-table .cpg-calc-num { text-align: right; white-space: nowrap; }
|
||||||
|
|
||||||
|
.cpg-calc-table .cpg-calc-name { width: 100%; min-width: 0; }
|
||||||
|
.cpg-calc-table .cpg-calc-qty { width: 100%; min-width: 0; text-align: right; padding-left: 6px; padding-right: 6px; }
|
||||||
|
.cpg-calc-table .cpg-calc-box { white-space: nowrap; font-size: 13px; }
|
||||||
|
.cpg-calc-table .cpg-calc-act { text-align: center; }
|
||||||
|
.cpg-calc-table .cpg-calc-del { padding: 2px 8px; line-height: 1.4; }
|
||||||
|
/* 제품명 칸을 좁게 고정하고, 나머지 열은 헤더가 잘리지 않을 폭을 준다.
|
||||||
|
fixed 레이아웃이라 남는 폭은 지정 폭 비율대로 나눠 가진다. */
|
||||||
|
.cpg-calc-table col.cpg-col-name { width: 180px; }
|
||||||
|
.cpg-calc-table col.cpg-col-qty { width: 78px; }
|
||||||
|
.cpg-calc-table col.cpg-col-box { width: 116px; }
|
||||||
|
.cpg-calc-table col.cpg-col-n { width: 64px; }
|
||||||
|
.cpg-calc-table col.cpg-col-act { width: 40px; }
|
||||||
|
/* fixed 레이아웃에서 내용이 셀 밖으로 삐져나와 가로 스크롤이 생기는 것을 막는다.
|
||||||
|
말줄임(…)은 글자 셀에만 준다 — input/button 이 든 셀에 주면 빈 "…" 이 그려진다. */
|
||||||
|
.cpg-calc-table th, .cpg-calc-table td { overflow: hidden; }
|
||||||
|
.cpg-calc-table thead th { white-space: nowrap; text-align: center; font-weight: 700; color: var(--color-rich-black); }
|
||||||
|
.cpg-calc-table .cpg-calc-box { text-overflow: ellipsis; }
|
||||||
|
/* 숫자열 헤더도 가운데 정렬(본문 셀은 우측 정렬 유지) */
|
||||||
|
.cpg-calc-table thead th.cpg-calc-num { text-align: center; }
|
||||||
|
.cpg-calc-row.cpg-calc-warn { background: color-mix(in srgb, var(--color-callout-red) 8%, transparent); }
|
||||||
|
.cpg-calc-actions { margin-top: 12px; gap: 8px; align-items: center; }
|
||||||
|
/* 상단선 정렬: 2열 그리드에서는 위 여백을 주지 않는다. */
|
||||||
|
.cpg-calc-sum { margin-top: 0; }
|
||||||
|
|
||||||
|
/* ── 박스 계산 요약 ─────────────────────────────── */
|
||||||
|
.cpg-sum-h3 {
|
||||||
|
margin: 20px 0 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.cpg-sum-h3 .erp-muted { font-weight: 400; font-size: 13px; }
|
||||||
|
|
||||||
|
.cpg-sum-kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.cpg-kpi {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-ghost-gray);
|
||||||
|
}
|
||||||
|
.cpg-kpi-label { font-size: 12px; color: var(--color-midtone-gray); }
|
||||||
|
.cpg-kpi-value { font-size: 24px; line-height: 1.2; }
|
||||||
|
.cpg-kpi-hint { font-size: 12px; color: var(--color-midtone-gray); }
|
||||||
|
|
||||||
|
.cpg-sum-prods {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.cpg-sum-prod {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.cpg-sum-prod-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.cpg-sum-prod-name { font-weight: 600; }
|
||||||
|
.cpg-sum-prod-nums {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cpg-sum-prod-nums strong { font-size: 17px; }
|
||||||
|
.cpg-sum-left { color: var(--color-callout-red); font-weight: 600; }
|
||||||
|
.cpg-sum-exact { color: var(--color-success-green); font-weight: 600; }
|
||||||
|
|
||||||
|
/* 혼합 박스 카드 */
|
||||||
|
.cpg-sum-boxes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.cpg-box-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
opacity: 0;
|
||||||
|
animation: cpg-box-in .38s ease-out forwards;
|
||||||
|
}
|
||||||
|
@keyframes cpg-box-in {
|
||||||
|
from { opacity: 0; transform: translateY(10px) scale(.96); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.cpg-box-art { flex: 0 0 auto; }
|
||||||
|
.cpg-box-svg { width: 72px; height: 64px; }
|
||||||
|
.cpg-box-body {
|
||||||
|
fill: #f6e2c3;
|
||||||
|
stroke: #b98a4a;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.cpg-box-flapL, .cpg-box-flapR {
|
||||||
|
fill: #e8cfa6;
|
||||||
|
stroke: #b98a4a;
|
||||||
|
stroke-width: 2;
|
||||||
|
transform-origin: 36px 20px;
|
||||||
|
animation: cpg-flap .5s ease-out both;
|
||||||
|
}
|
||||||
|
.cpg-box-flapL { animation-delay: .12s; }
|
||||||
|
.cpg-box-flapR { animation-delay: .2s; }
|
||||||
|
@keyframes cpg-flap {
|
||||||
|
from { transform: rotate(0deg) translateY(6px); opacity: .3; }
|
||||||
|
to { transform: none; opacity: 1; }
|
||||||
|
}
|
||||||
|
.cpg-box-fill {
|
||||||
|
fill: var(--color-rich-black);
|
||||||
|
opacity: .16;
|
||||||
|
animation: cpg-fill-up .55s ease-out both;
|
||||||
|
transform-origin: 36px 54px;
|
||||||
|
}
|
||||||
|
@keyframes cpg-fill-up {
|
||||||
|
from { transform: scaleY(0); }
|
||||||
|
to { transform: scaleY(1); }
|
||||||
|
}
|
||||||
|
.cpg-box-seam { stroke: #b98a4a; stroke-width: 1.5; opacity: .5; }
|
||||||
|
|
||||||
|
.cpg-box-info { min-width: 0; flex: 1 1 auto; }
|
||||||
|
.cpg-box-title { font-weight: 700; margin-bottom: 6px; }
|
||||||
|
.cpg-box-items {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cpg-box-items li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px dashed var(--color-subtle-ash);
|
||||||
|
}
|
||||||
|
.cpg-box-items li:last-child { border-bottom: 0; }
|
||||||
|
.cpg-box-bar {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--color-ghost-gray);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cpg-box-bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-rich-black);
|
||||||
|
animation: cpg-bar-grow .6s ease-out both;
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
@keyframes cpg-bar-grow {
|
||||||
|
from { transform: scaleX(0); }
|
||||||
|
to { transform: scaleX(1); }
|
||||||
|
}
|
||||||
|
.cpg-box-fillnum {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.cpg-box-card, .cpg-box-fill, .cpg-box-flapL, .cpg-box-flapR, .cpg-box-bar span {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.cpg-box-fill { opacity: .16; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 박스 계산 2열 레이아웃 — 왼쪽 입력표 / 오른쪽 요약. 좁으면 세로로 쌓임. */
|
||||||
|
.cpg-calc-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.cpg-calc-layout > .erp-card { margin-bottom: 0; }
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.cpg-calc-layout { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 행이 많아도 카드가 잘리지 않도록 표 자체를 스크롤시킨다. */
|
||||||
|
.cpg-calc-card .erp-table-wrap {
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: min(58vh, 560px);
|
||||||
|
}
|
||||||
|
.cpg-calc-card .cpg-calc-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
}
|
||||||
|
.cpg-calc-actions { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* 1열/2열 카드 높이를 맞춘다(요약이 비어 있을 때는 왼쪽만 표시). */
|
||||||
|
.cpg-calc-layout { align-items: stretch; }
|
||||||
|
.cpg-calc-layout > .erp-card { display: flex; flex-direction: column; }
|
||||||
|
.cpg-calc-card .erp-table-wrap { flex: 1 1 auto; }
|
||||||
|
.cpg-calc-sum { overflow: auto; max-height: min(78vh, 900px); }
|
||||||
|
|
||||||
|
/* 제품명 설정 — 수기 추가 폼 */
|
||||||
|
.cpg-prod-manual {
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-ghost-gray);
|
||||||
|
}
|
||||||
|
.cpg-prod-manual-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.cpg-prod-manual-head .erp-muted { font-size: 12px; }
|
||||||
|
.cpg-prod-manual-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cpg-prod-manual-row .erp-field { flex: 1 1 160px; min-width: 0; }
|
||||||
|
.cpg-prod-manual-row .erp-input { min-width: 0; width: 100%; }
|
||||||
|
|
||||||
|
/* 상자 그림 안 박스 호수 라벨 (예: 2호) */
|
||||||
|
.cpg-box-label {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
fill: #8a5a1e;
|
||||||
|
paint-order: stroke;
|
||||||
|
stroke: rgba(255, 255, 255, .85);
|
||||||
|
stroke-width: 3px;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 박스 계산 3열 작업 영역 (① 계산 / ② 요약 / ③ 센터 분배) ── */
|
||||||
|
.cpg-calc3 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.25fr) minmax(0, 0.9fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.cpg-calc3 > .erp-card { margin-bottom: 0; display: flex; flex-direction: column; }
|
||||||
|
@media (max-width: 1500px) {
|
||||||
|
.cpg-calc3 { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||||
|
.cpg-calc3 > .cpg-dist-card { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.cpg-calc3 { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.cpg-calc3 > .cpg-dist-card { grid-column: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ② 요약: 카드가 드래그 가능함을 알린다 */
|
||||||
|
.cpg-sum-prod.is-draggable, .cpg-box-card.is-draggable { cursor: grab; }
|
||||||
|
.cpg-sum-prod.is-draggable:hover, .cpg-box-card.is-draggable:hover {
|
||||||
|
border-color: var(--color-rich-black);
|
||||||
|
}
|
||||||
|
.cpg-sum-prod.is-dragging, .cpg-box-card.is-dragging { opacity: .35; }
|
||||||
|
.cpg-sum-prod.is-done, .cpg-box-card.is-done { opacity: .5; }
|
||||||
|
|
||||||
|
/* 커서를 따라다니는 복제 카드 */
|
||||||
|
body.cpg-dragging { cursor: grabbing; user-select: none; }
|
||||||
|
.cpg-drag-ghost {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1300;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: .9;
|
||||||
|
max-width: 320px;
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
border: 1px solid var(--color-rich-black);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, .25);
|
||||||
|
transform: rotate(-1.5deg);
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ③ 센터 분배 */
|
||||||
|
.cpg-dist-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: min(72vh, 820px);
|
||||||
|
}
|
||||||
|
.cpg-dist-center {
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.cpg-dist-center.has-items { border-color: var(--color-rich-black); }
|
||||||
|
.cpg-dist-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
/* 드롭 대상은 센터 패널 전체 */
|
||||||
|
.cpg-dist-center { transition: background .15s ease, border-color .15s ease; }
|
||||||
|
.cpg-dist-center.is-over {
|
||||||
|
border-color: var(--color-rich-black);
|
||||||
|
background: var(--color-ghost-gray);
|
||||||
|
}
|
||||||
|
.cpg-dist-hint {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border: 1.5px dashed var(--color-subtle-ash);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cpg-dist-sum { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.cpg-dist-close { padding: 0 6px; line-height: 1.5; }
|
||||||
|
.cpg-center-pick { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||||
|
.cpg-center-pick .erp-select { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.cpg-dist-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.cpg-dist-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cpg-dist-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.cpg-dist-qty { width: 62px; min-width: 0; text-align: right; padding: 2px 6px; }
|
||||||
|
.cpg-dist-unit { font-size: 12px; color: var(--color-midtone-gray); }
|
||||||
|
.cpg-dist-del { padding: 2px 8px; line-height: 1.4; }
|
||||||
|
|
||||||
|
/* 박스 수량 입력 대화상자 */
|
||||||
|
.cpg-modal { position: fixed; inset: 0; z-index: 1200; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.cpg-modal[hidden] { display: none; }
|
||||||
|
.cpg-modal-back { position: absolute; inset: 0; background: rgba(10, 10, 10, .45); }
|
||||||
|
.cpg-modal-box {
|
||||||
|
position: relative;
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 18px 48px rgba(0, 0, 0, .28);
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.cpg-modal-box h3 { margin: 0; font-size: 17px; }
|
||||||
|
.cpg-dlg-item { margin: 0; font-weight: 600; }
|
||||||
|
.cpg-dlg-actions { margin: 4px 0 0; flex-wrap: wrap; }
|
||||||
|
.cpg-modal-box .erp-input, .cpg-modal-box .erp-select { width: 100%; min-width: 0; }
|
||||||
|
|
||||||
|
/* 드래그 중일 때 ③ 영역을 강조 — 어디에 놓아야 하는지 보이게 */
|
||||||
|
.cpg-dist-card.is-drop-ready { outline: 2px dashed var(--color-rich-black); outline-offset: 2px; }
|
||||||
|
.cpg-dist-card.is-drop-ready .cpg-dist-center { border-color: var(--color-midtone-gray); }
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/* ════════════════════════════════════════════════════
|
||||||
|
ERP 공용 확인/경고 창
|
||||||
|
브라우저 기본 confirm()/alert() 은 제목에 도메인이 찍히고 위치·모양을 바꿀 수
|
||||||
|
없다. 화면 정중앙에 같은 모양으로 띄우기 위해 직접 그린다(erp-dialog.js).
|
||||||
|
════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.erp-dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center; /* 화면 정중앙 */
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-16, 16px);
|
||||||
|
background: rgba(10, 10, 10, 0.45);
|
||||||
|
font-family: var(--font-geist, system-ui, sans-serif);
|
||||||
|
animation: erp-dialog-fade 0.12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: var(--color-canvas-white, #fff);
|
||||||
|
border-radius: var(--r-card, 14px);
|
||||||
|
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center; /* 제목·본문·버튼 모두 가운데 정렬 */
|
||||||
|
animation: erp-dialog-pop 0.12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-dialog-title {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--sp-12, 12px) var(--sp-20, 20px);
|
||||||
|
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
background: var(--color-ghost-gray, #f2f2f2);
|
||||||
|
font-size: var(--text-body, 14px);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: var(--tracking-heading, -0.45px);
|
||||||
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 아이콘은 글 왼쪽에 고정하고, 글 묶음만 가운데 정렬한다. */
|
||||||
|
.erp-dialog-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sp-12, 12px);
|
||||||
|
padding: var(--sp-24, 24px) var(--sp-20, 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-dialog-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
line-height: 0;
|
||||||
|
color: var(--color-callout-red, #c22b10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 확인(질문)용은 붉은 경고색 대신 차분한 검정으로 */
|
||||||
|
.erp-dialog-ask .erp-dialog-icon {
|
||||||
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-dialog-message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-body, 14px);
|
||||||
|
line-height: var(--leading-body, 1.43);
|
||||||
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
|
/* 메시지의 줄바꿈(\n)을 그대로 살린다 */
|
||||||
|
white-space: pre-line;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
padding: 0 var(--sp-20, 20px) var(--sp-20, 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-dialog-actions .erp-btn {
|
||||||
|
min-width: 88px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
font-size: var(--text-body, 14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes erp-dialog-fade {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes erp-dialog-pop {
|
||||||
|
from { opacity: 0; transform: translateY(6px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.erp-dialog-backdrop,
|
||||||
|
.erp-dialog { animation: none; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/* ════════════════════════════════════════════════════
|
||||||
|
ERP 공용 확인/경고 창 — window.confirm()/alert() 대체
|
||||||
|
────────────────────────────────────────────────────
|
||||||
|
브라우저 기본 창은 제목에 도메인("dbx.no1king.freeddns.org 내용:")이 찍히고
|
||||||
|
위치·정렬·아이콘을 바꿀 수 없다. 그래서 같은 역할의 창을 직접 그린다.
|
||||||
|
|
||||||
|
쓰는 법 (기본 창과 달리 **비동기**다 — Promise 를 돌려준다):
|
||||||
|
erpConfirm("지울까요?").then(function (ok) { if (ok) ... });
|
||||||
|
erpAlert("실패했습니다.").then(...)
|
||||||
|
|
||||||
|
HTML 만으로 쓰려면 form 에 data-erp-confirm 을 달면 된다. 제출을 가로채
|
||||||
|
확인을 받은 뒤 통과시킨다(가로챈 제출은 form.submit() 으로 다시 보내므로
|
||||||
|
이 스크립트의 submit 리스너를 다시 타지 않는다).
|
||||||
|
<form ... data-erp-confirm="정말 지울까요?">
|
||||||
|
════════════════════════════════════════════════════ */
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var TITLE = "No.1 King ERP 프로그램";
|
||||||
|
|
||||||
|
// 경고 삼각형. 외부 아이콘 라이브러리를 쓰지 않는다(자체 호스팅 원칙).
|
||||||
|
var ICON =
|
||||||
|
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
|
||||||
|
'<path d="M12 3.2 1.8 20.4h20.4L12 3.2Z" stroke="currentColor" stroke-width="1.6"' +
|
||||||
|
' stroke-linejoin="round"/>' +
|
||||||
|
'<path d="M12 9.4v4.8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>' +
|
||||||
|
'<circle cx="12" cy="17.1" r="1.05" fill="currentColor"/>' +
|
||||||
|
"</svg>";
|
||||||
|
|
||||||
|
function build(message, options) {
|
||||||
|
var backdrop = document.createElement("div");
|
||||||
|
backdrop.className = "erp-dialog-backdrop";
|
||||||
|
|
||||||
|
var box = document.createElement("div");
|
||||||
|
box.className = "erp-dialog" + (options.ask ? " erp-dialog-ask" : "");
|
||||||
|
box.setAttribute("role", "alertdialog");
|
||||||
|
box.setAttribute("aria-modal", "true");
|
||||||
|
box.setAttribute("aria-label", TITLE);
|
||||||
|
|
||||||
|
var title = document.createElement("h2");
|
||||||
|
title.className = "erp-dialog-title";
|
||||||
|
title.textContent = TITLE;
|
||||||
|
|
||||||
|
var body = document.createElement("div");
|
||||||
|
body.className = "erp-dialog-body";
|
||||||
|
|
||||||
|
var icon = document.createElement("span");
|
||||||
|
icon.className = "erp-dialog-icon";
|
||||||
|
icon.innerHTML = ICON;
|
||||||
|
|
||||||
|
var text = document.createElement("p");
|
||||||
|
text.className = "erp-dialog-message";
|
||||||
|
// 메시지는 반드시 textContent 로 넣는다 — 서버/사용자 문자열이 들어와도
|
||||||
|
// HTML 로 해석되지 않게.
|
||||||
|
text.textContent = String(message == null ? "" : message);
|
||||||
|
|
||||||
|
var actions = document.createElement("div");
|
||||||
|
actions.className = "erp-dialog-actions";
|
||||||
|
|
||||||
|
var cancel = null;
|
||||||
|
if (options.ask) {
|
||||||
|
cancel = document.createElement("button");
|
||||||
|
cancel.type = "button";
|
||||||
|
cancel.className = "erp-btn erp-btn-outline";
|
||||||
|
cancel.textContent = options.cancelText || "취소";
|
||||||
|
actions.appendChild(cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok = document.createElement("button");
|
||||||
|
ok.type = "button";
|
||||||
|
ok.className = "erp-btn erp-btn-primary";
|
||||||
|
ok.textContent = options.okText || "확인";
|
||||||
|
actions.appendChild(ok);
|
||||||
|
|
||||||
|
body.appendChild(icon);
|
||||||
|
body.appendChild(text);
|
||||||
|
box.appendChild(title);
|
||||||
|
box.appendChild(body);
|
||||||
|
box.appendChild(actions);
|
||||||
|
backdrop.appendChild(box);
|
||||||
|
|
||||||
|
return { backdrop: backdrop, box: box, ok: ok, cancel: cancel };
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(message, options) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var el = build(message, options);
|
||||||
|
var previous = document.activeElement;
|
||||||
|
var closed = false;
|
||||||
|
|
||||||
|
function close(result) {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
document.removeEventListener("keydown", onKey, true);
|
||||||
|
el.backdrop.remove();
|
||||||
|
if (previous && typeof previous.focus === "function") previous.focus();
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
close(false); // 확인창의 Esc = 취소, 알림창의 Esc = 닫기
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
close(true);
|
||||||
|
} else if (e.key === "Tab") {
|
||||||
|
// 포커스가 창 밖으로 나가지 않게 두 버튼 사이에서만 돈다.
|
||||||
|
var focusable = el.cancel ? [el.cancel, el.ok] : [el.ok];
|
||||||
|
var index = focusable.indexOf(document.activeElement);
|
||||||
|
e.preventDefault();
|
||||||
|
var next = e.shiftKey ? index - 1 : index + 1;
|
||||||
|
if (next < 0) next = focusable.length - 1;
|
||||||
|
if (next >= focusable.length) next = 0;
|
||||||
|
focusable[next].focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el.ok.addEventListener("click", function () { close(true); });
|
||||||
|
if (el.cancel) el.cancel.addEventListener("click", function () { close(false); });
|
||||||
|
// 바깥을 누르면 취소(= 안전한 쪽). 창 안쪽 클릭은 그대로 둔다.
|
||||||
|
el.backdrop.addEventListener("mousedown", function (e) {
|
||||||
|
if (e.target === el.backdrop) close(false);
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", onKey, true);
|
||||||
|
|
||||||
|
document.body.appendChild(el.backdrop);
|
||||||
|
el.ok.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.erpConfirm = function (message, options) {
|
||||||
|
var opts = options || {};
|
||||||
|
opts.ask = true;
|
||||||
|
return open(message, opts);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.erpAlert = function (message, options) {
|
||||||
|
var opts = options || {};
|
||||||
|
opts.ask = false;
|
||||||
|
return open(message, opts).then(function () { return undefined; });
|
||||||
|
};
|
||||||
|
|
||||||
|
// data-erp-confirm 이 달린 폼은 제출 전에 확인을 받는다.
|
||||||
|
document.addEventListener("submit", function (e) {
|
||||||
|
var form = e.target;
|
||||||
|
if (!form || !form.dataset || !form.dataset.erpConfirm) return;
|
||||||
|
if (form.dataset.erpConfirmed === "1") return; // 확인을 마치고 다시 보낸 것
|
||||||
|
e.preventDefault();
|
||||||
|
window.erpConfirm(form.dataset.erpConfirm).then(function (ok) {
|
||||||
|
if (!ok) return;
|
||||||
|
form.dataset.erpConfirmed = "1";
|
||||||
|
form.submit(); // submit 이벤트를 다시 타지 않는다
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -31,6 +31,7 @@ MODULE_KEYS: tuple[str, ...] = (
|
|||||||
"malaysia",
|
"malaysia",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
"project",
|
"project",
|
||||||
|
"cafe24",
|
||||||
"expense_approver",
|
"expense_approver",
|
||||||
"vacation_approver",
|
"vacation_approver",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<link rel="stylesheet" href="/static/erp.css?v=20260615b" />
|
<link rel="stylesheet" href="/static/erp.css?v=20260615b" />
|
||||||
<link rel="stylesheet" href="/static/erp-shell.css?v=20260615b" />
|
<link rel="stylesheet" href="/static/erp-shell.css?v=20260615b" />
|
||||||
<link rel="stylesheet" href="/static/erp-attach-viewer.css" />
|
<link rel="stylesheet" href="/static/erp-attach-viewer.css" />
|
||||||
|
<link rel="stylesheet" href="/static/erp-dialog.css?v=20260819a" />
|
||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="erp-body erp-app-body">
|
<body class="erp-body erp-app-body">
|
||||||
@@ -161,6 +162,9 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{# 공용 확인/경고 창. defer 를 쓰지 않는다 — 아래 {% block scripts %} 의
|
||||||
|
인라인 스크립트가 곧바로 erpConfirm/erpAlert 을 쓸 수 있어야 한다. #}
|
||||||
|
<script src="/static/erp-dialog.js?v=20260819a"></script>
|
||||||
<script src="/static/erp-attach-viewer.js" defer></script>
|
<script src="/static/erp-attach-viewer.js" defer></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -18,6 +18,27 @@ services:
|
|||||||
- default
|
- default
|
||||||
- postgres_default # postgres-db 컨테이너와 통신 (DSN host=postgres-db)
|
- postgres_default # postgres-db 컨테이너와 통신 (DSN host=postgres-db)
|
||||||
|
|
||||||
|
# 카페24 예약 실행기 — web 과 같은 이미지로 worker 모듈만 돌린다.
|
||||||
|
# 예약은 브라우저를 닫아도 그 시각에 실행돼야 하므로 별도 프로세스가 필요하다.
|
||||||
|
# CAFE24_DB_URL 이 없으면 즉시 종료하며 로그로 사유를 남긴다.
|
||||||
|
cafe24-worker:
|
||||||
|
build: .
|
||||||
|
image: dbx-main:latest
|
||||||
|
container_name: dbx-cafe24-worker
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["python", "-m", "app.modules.cafe24.worker", "--loop", "60"]
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DATA_DIR: /data
|
||||||
|
volumes:
|
||||||
|
- dbx-main-data:/data
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- postgres_default
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
dbx-main-data:
|
dbx-main-data:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
# 카페24 상품 상세페이지 관리 모듈 (cafe24)
|
||||||
|
|
||||||
|
> 카페24 관리자 페이지에 직접 들어가지 않고 상품 상세페이지를 조회·편집·예약
|
||||||
|
> 적용하고, 언제든 이전 상태로 되돌리기 위한 운영용 모듈.
|
||||||
|
> main-app ERP 에 편입된 **모듈**이다(별도 앱/포트 아님). 인증은 기존 Google
|
||||||
|
> OAuth + 권한키 `cafe24` 를 재사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 구조 — 왜 두 곳으로 나눴나
|
||||||
|
|
||||||
|
향후 **카페24 주문관리**(주문 조회·송장 일괄등록·취소/반품/교환)를 같은
|
||||||
|
프로젝트에 추가할 예정이다. 그래서 카페24 인증/전송은 상품관리에 종속시키지
|
||||||
|
않고 공통 계층으로 분리했다.
|
||||||
|
|
||||||
|
```
|
||||||
|
app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리 공유)
|
||||||
|
├─ config.py 환경변수 → Cafe24Config (하드코딩 금지)
|
||||||
|
├─ crypto.py 토큰 Fernet 암복호화
|
||||||
|
├─ oauth.py 인증 URL / code→token / refresh
|
||||||
|
├─ tokens.py TokenService — 저장·만료판정·자동갱신(행 잠금)
|
||||||
|
├─ client.py Cafe24Client — 전송·재시도·401/429/5xx·호출간격·API 로그
|
||||||
|
├─ products.py 상품 엔드포인트 래퍼 (향후 orders.py 를 형제로 추가)
|
||||||
|
└─ errors.py 공통 예외
|
||||||
|
|
||||||
|
app/modules/cafe24/ ← 상품관리 모듈
|
||||||
|
├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합
|
||||||
|
├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기)
|
||||||
|
├─ routes_schedules.py 예약 등록·목록·취소
|
||||||
|
├─ worker.py 예약 실행기 (compose 서비스 dbx-cafe24-worker)
|
||||||
|
├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그
|
||||||
|
├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리)
|
||||||
|
├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL)
|
||||||
|
├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증
|
||||||
|
├─ tests/ DB/네트워크 없는 유닛테스트
|
||||||
|
└─ templates/cafe24/ _nav.html · products.html(2분할) ·
|
||||||
|
_editor.html(오른쪽 조각) ·
|
||||||
|
schedules.html · system.html
|
||||||
|
```
|
||||||
|
|
||||||
|
**규칙: 라우터에서 `httpx`/`requests` 를 직접 부르지 않는다.** 반드시
|
||||||
|
`app.integrations.cafe24` 의 `Cafe24Client` 를 통한다(재시도·로그·토큰 갱신이
|
||||||
|
한 곳에 모여 있어야 하기 때문).
|
||||||
|
|
||||||
|
핸들러는 `async def` 가 아니라 **`def`(동기)** 로 선언한다. 카페24 API·DB 호출이
|
||||||
|
블로킹이므로 FastAPI 스레드풀에서 돌게 두는 편이 이벤트 루프를 막지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 화면 / 경로
|
||||||
|
|
||||||
|
| 경로 | 화면 | 권한 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /cafe24/` | 2분할 화면 (`q`, `display`, `selling`, `selected`) | `cafe24` |
|
||||||
|
| `GET /cafe24/products/{product_no}/pane` | 오른쪽 편집기 조각 (JS 가 가져감) | `cafe24` |
|
||||||
|
| `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `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}/name` | 상품명 변경 (JSON: `{name}` → 적용된 이름). 이전 이름은 감사로그 `rename_product` | `cafe24` |
|
||||||
|
| `GET /cafe24/schedules` | 예약 목록 · 취소 | `cafe24` |
|
||||||
|
| `POST /cafe24/schedules` | 예약 등록 (편집기에서) | `cafe24` |
|
||||||
|
| `POST /cafe24/schedules/{id}/cancel` | 대기 중 예약 취소 | `cafe24` |
|
||||||
|
| `GET /cafe24/schedules/preview/{product_no}` | 현재 진열/판매 상태 (JSON) | `cafe24` |
|
||||||
|
| `GET /cafe24/system` | 연결 상태 · API 로그 · 작업 로그 | `cafe24` |
|
||||||
|
| `GET /cafe24/system/oauth/start` | 카페24 인증 시작 | **admin** |
|
||||||
|
| `GET /cafe24/oauth/callback` | 카페24 콜백 (code→토큰) | **admin** |
|
||||||
|
| `POST /cafe24/system/oauth/disconnect` | 저장된 토큰 삭제 | **admin** |
|
||||||
|
| `GET /cafe24/health` | 포털 카드 상태 점 | 없음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2-1. 상품관리 화면 구성 (2분할)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 550px ───────────────────┬───────── 남은 폭 전부 ─────────┐
|
||||||
|
│ 상품명 검색(한 줄) │ 상품 이름 · 번호 · 진열/판매 │
|
||||||
|
│ ☑진열중 ☑판매중 (기본 체크) │ 상세설명 HTML 편집기 │
|
||||||
|
│ ── 목록(전체, 스크롤) ── │ (PC/모바일 공통 · 문법 강조) │
|
||||||
|
│ 번호 상품명 진열 판매 수정 │ [메모][복사][다시 읽기][적용] │
|
||||||
|
│ (제목행 클릭 = 정렬) │ ▸ 버전 이력 │
|
||||||
|
│ │ │
|
||||||
|
└───────────────────────────┴─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 검색 입력란은 **한 줄 높이로 고정**한다(`height: 32px`). `.cf24-filters` 가 세로
|
||||||
|
flex 이므로 `flex-basis` 를 주면 그 값이 **높이**로 적용돼 입력란이 거대해진다.
|
||||||
|
실제로 그 사고가 있었다 — flex 방향을 항상 확인할 것.
|
||||||
|
|
||||||
|
- **왼쪽은 전체 목록**(페이지 없음). `list_all_products` 로 페이지를 넘겨가며 전부
|
||||||
|
받는다(1회 100개, 상한 1000개). 필터를 한 페이지에만 적용하면 다음 페이지의
|
||||||
|
해당 상품이 빠지기 때문이다.
|
||||||
|
- **필터**는 `진열중`/`판매중` 체크박스이며 **중복 선택 시 AND** 다. 문서에 없는 API
|
||||||
|
파라미터에 기대지 않고 받아온 뒤 파이썬에서 걸러낸다.
|
||||||
|
**기본값은 둘 다 체크**다. 체크박스는 해제 상태면 아무 값도 보내지 않으므로,
|
||||||
|
폼에 표식(`f=1`)을 함께 넣어 "첫 방문"과 "사용자가 일부러 해제함"을 구분한다.
|
||||||
|
표식이 없으면 기본값(둘 다 체크)으로 보고, 있으면 실제 체크 상태를 따른다.
|
||||||
|
이 표식은 `_list_query` 가 링크·리다이렉트에도 이어 붙여 해제 상태가 유지된다.
|
||||||
|
- **정렬**은 제목행 클릭(오름↔내림 토글). 브라우저에서 처리하므로 전체를 받아둔
|
||||||
|
덕분에 목록 전체가 대상이 된다.
|
||||||
|
- **상품 클릭 시 오른쪽만 교체**한다(`/pane` 조각을 fetch → 삽입). 목록을 다시 받지
|
||||||
|
않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다.
|
||||||
|
- 편집기 상단에 **상품 다이렉트 주소**(고객이 보는 상세페이지 URL)와 「주소 복사」·
|
||||||
|
「쇼핑몰에서 열기」를 둔다. 주소는 `CAFE24_SHOP_URL` 기준으로 만들고, 미설정 시
|
||||||
|
카페24 기본 도메인(`https://<mall_id>.cafe24.com`)으로 대체한다 — 커스텀 도메인은
|
||||||
|
`mall_id` 로 알 수 없으므로 환경변수가 필요하다.
|
||||||
|
- 편집 중 다른 상품을 클릭하거나 페이지를 벗어나면 **저장 안 됨 경고**가 뜬다.
|
||||||
|
- **캐시 금지.** 화면·조각 응답에 `Cache-Control: no-store` 를 붙이고 조각 fetch 에도
|
||||||
|
`cache: "no-store"` 를 건다. 캐시된 조각이 다시 그려지면 카페24 관리자에서 값을 바꾼
|
||||||
|
뒤에도 예전 소스가 보이고, 그것을 그대로 편집하면 남의 수정을 덮어쓴다.
|
||||||
|
편집기의 **[다시 읽기]** 버튼으로 언제든 현재값을 강제로 다시 받을 수 있다.
|
||||||
|
- `.erp-page` 의 `max-width` 를 이 화면에서만 풀어 편집 영역을 넓게 쓴다. 이때
|
||||||
|
`box-sizing: border-box` 를 함께 줘야 한다(안 주면 padding 이 폭에 더해져 문서에
|
||||||
|
가로 스크롤이 생긴다).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2-2. 편집기 (문법 강조 · 소스 정리)
|
||||||
|
|
||||||
|
**문법 강조** — 색칠된 `<pre>` 위에 **투명한 `<textarea>`** 를 정확히 겹쳐 놓는
|
||||||
|
방식이다. 외부 라이브러리를 쓰지 않는다(자체 호스팅 원칙).
|
||||||
|
|
||||||
|
- 두 층의 **폰트·글자크기·줄높이·padding·`white-space`·`tab-size` 가 완전히 같아야**
|
||||||
|
글자가 어긋나지 않는다. 줄 번호 칸(`.cf24-gutter`)도 같은 글꼴 지표를 써야 줄이
|
||||||
|
맞는다. `cafe24.css` 의 세 선택자를 항상 함께 수정할 것.
|
||||||
|
- **스크롤 주체는 `textarea` 다.** 색칠 층과 줄 번호를 `transform` 으로 같은 양만큼
|
||||||
|
이동시켜 맞춘다(`scroll` 이벤트에서 `translate(-scrollLeft, -scrollTop)`).
|
||||||
|
크기를 계산해 맞추는 방식은 두 번 실패했다 — ① textarea 의 `scrollHeight` 는
|
||||||
|
브라우저가 한두 줄 더 잡는다(실측 830 vs 792), ② flex 자식에서 `width: max-content`
|
||||||
|
가 기대대로 적용되지 않아 긴 줄이 있으면 textarea 만 내부 스크롤되고 색칠 층은
|
||||||
|
제자리에 남는다(실측 706 vs 686). 그 상태에서는 **커서를 둔 곳과 다른 위치에 글자가
|
||||||
|
입력된다.** 스크롤 동기화는 크기 계산이 없어 어긋날 여지가 없다.
|
||||||
|
- **줄바꿈하지 않고 가로로 스크롤한다**(`white-space: pre` + `wrap="off"`).
|
||||||
|
줄바꿈을 허용하면 한 논리 줄이 여러 행이 되어 줄 번호가 맞지 않는다.
|
||||||
|
- 줄 번호 칸은 코드 영역 왼쪽의 **독립 박스**다(가로 스크롤과 무관하며 세로만 따라간다).
|
||||||
|
- 코드 칸 높이는 `56vh` **고정**이다(`flex: 0 0 auto`). 아래 「예약 적용」·「버전 이력」을
|
||||||
|
펼쳐도 편집기가 눌리지 않고 내용이 아래로 밀리며 오른쪽 칸이 스크롤된다. `flex: 1 1 auto`
|
||||||
|
였을 때는 펼칠 때마다 편집기가 작아져 작업 위치를 잃었다. 56vh 는 접힌 상태에서 두
|
||||||
|
요약줄까지 스크롤 없이 들어오는 값이다(실측: 62vh 는 51px 넘침, 56vh 는 0).
|
||||||
|
- 20만 자를 넘으면 강조를 끄고 평문으로 보여준다(타이핑마다 재색칠하면 느려짐).
|
||||||
|
- 색: 태그 초록 / 속성이름 갈색 / 속성값 남색 / 주석 회색 기울임 / 기호 회색.
|
||||||
|
|
||||||
|
**소스 정리** — `store.format_html()`. 화면에 보여줄 때와 저장할 때 **같은 함수**를
|
||||||
|
쓰므로, 화면에서 본 소스가 그대로 카페24에 저장된다.
|
||||||
|
|
||||||
|
- 줄을 나누는 것은 **블록 요소 경계에서만** 한다. HTML 에서 공백은 의미가 있어서
|
||||||
|
인라인 요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다(이미지 사이가 벌어지는
|
||||||
|
고전적인 사고). `img`·`br`·`span`·`a` 는 블록 목록에서 **의도적으로 제외**했다.
|
||||||
|
- **원문에 이미 있던 줄바꿈은 살리고, 각 줄을 현재 깊이로 들여쓴다.** 상세페이지는
|
||||||
|
`<img>` 를 한 줄에 하나씩 적어두는 경우가 많고 그 모양이 저자의 의도다. 줄 앞
|
||||||
|
공백은 렌더링에 영향이 없으므로 들여쓰기는 안전하다.
|
||||||
|
- 주석은 줄을 강제로 나누지 않는다. `<!-- 대파_타임랩스 --><img ...>` 처럼 바로 뒤
|
||||||
|
요소를 설명하는 주석이 많아, 나누면 라벨과 대상이 떨어져 오히려 읽기 나빠진다.
|
||||||
|
- 구획용 **빈 줄은 한 줄까지 유지**한다(여러 줄은 하나로 줄인다).
|
||||||
|
- `<style>`·`<script>`·`<pre>`·`<textarea>` 안쪽은 한 글자도 건드리지 않는다.
|
||||||
|
- 내용이 한 줄뿐인 짧은 블록은 다시 한 줄로 합친다(`<td>1</td>`).
|
||||||
|
- **멱등**이다 — 편집하지 않고 다시 적용해도 저장값이 계속 바뀌지 않는다(테스트로 고정).
|
||||||
|
- 닫는 태그가 빠진 HTML 이 흔하므로 들여쓰기 깊이에 상한(12)을 둔다. 어떤 이유로든
|
||||||
|
실패하면 **원본을 그대로** 돌려준다(정리보다 안 깨지는 게 중요).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2-3. 예약관리 (지정 시각 자동 적용)
|
||||||
|
|
||||||
|
**되돌리기(자동 복원)는 쓰지 않는다.** 예약은 "그 시각에 이 내용을 적용" 하나뿐이다.
|
||||||
|
한 예약에서 세 가지를 각각 고를 수 있고, 하나 이상은 반드시 골라야 한다(DB CHECK 제약).
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| 상세페이지 HTML | 편집기 내용을 적용 / 적용 안 함 |
|
||||||
|
| 진열 | 진열 · 미진열 · 변경 없음 |
|
||||||
|
| 판매 | 판매 · 중지 · 변경 없음 |
|
||||||
|
|
||||||
|
- **등록**은 편집기 아래 「예약 적용」에서 한다. HTML 을 적용하는 예약이면 그 시점의
|
||||||
|
편집기 내용을 **DRAFT revision 으로 저장해 고정**한다 — 이후 편집기를 더 고쳐도
|
||||||
|
예약된 내용은 바뀌지 않는다(예약해둔 것이 조용히 달라지면 안 된다).
|
||||||
|
단건 적용과 같은 다듬기(URL 인코딩 → 소스 정리)를 거치므로 화면에서 본 값이 저장된다.
|
||||||
|
- 예약 폼은 적용 폼과 **형제**로 둔다(HTML 은 폼 중첩을 허용하지 않는다). 편집기 내용은
|
||||||
|
JS 가 hidden 에 복사해 함께 보낸다.
|
||||||
|
- **입력은 날짜/시간 별도 input** 이다(`type=date` + `type=time`). `datetime-local`
|
||||||
|
단일 입력은 한국어 로캘에서 표시 폭이 브라우저마다 달라 잘리는 사고가 있었다(실제
|
||||||
|
발생 — "2026. 08. 14. 오후 07:00" 형태).
|
||||||
|
- **보이는 글자는 우리가 직접 그린다** — 네이티브 date/time input 은 표시 형식을
|
||||||
|
CSS 로 바꿀 수 없어서(브라우저·로캘가 강제), 코드 편집기(`.cf24-code-input` 위
|
||||||
|
`.cf24-code-hl`)와 같은 원리로 투명한(`opacity:0`) 네이티브 input 을 형식화한
|
||||||
|
텍스트(`.cf24-dt-display`, `"2026년 08월 20일 (목)"` / `"오후 07시 30분"`) 위에
|
||||||
|
완전히 겹친다. `input`/`change` 이벤트마다 JS(`paintDate`/`paintTime`)가 다시
|
||||||
|
그린다. 날짜에는 요일도 붙인다(`new Date(y, mo-1, d).getDay()` — 문자열을 그대로
|
||||||
|
`new Date("YYYY-MM-DD")` 로 파싱하면 UTC 로 해석돼 하루 밀릴 수 있어 연/월/일을
|
||||||
|
분해해 로컬 시간대로 만든다).
|
||||||
|
- **클릭은 칸 전체 어디서든 반응한다.** 네이티브 date/time input 은 기본적으로
|
||||||
|
자신의 달력 아이콘(우측 끝 작은 영역)을 클릭해야만 팝업이 뜨고, 칸 전체를
|
||||||
|
덮도록 늘려도 그 작은 아이콘 영역만 반응한다(실제 겪은 문제 — "오른쪽 부분을
|
||||||
|
선택해야 나온다"). `showPicker()` 를 클릭 핸들러에서 직접 호출해 칸 어디를
|
||||||
|
클릭해도 팝업이 뜨게 했다. 미지원 브라우저에서는 조용히 무시되고 기존처럼
|
||||||
|
포커스만 이동한다(기능 저하 없이 안전하게 대체).
|
||||||
|
- 글자크기 12px, 날짜 칸은 시간 칸보다 넓게(`flex: 1.7` vs `1`) 잡는다 — 날짜 칸에
|
||||||
|
요일까지 들어가 더 길기 때문이다. 값은 실측으로 잘리지 않는 조합을 골랐다.
|
||||||
|
- 제출 직전 JS 가 두 input 의 `value`(형식과 무관하게 항상 `YYYY-MM-DD` / `HH:MM`)를
|
||||||
|
`"YYYY-MM-DD" + "T" + "HH:MM"` 로 합쳐 hidden `scheduled_at` 에 넣는다 —
|
||||||
|
서버(`store.parse_schedule_at`)는 예전과 같은 형식을 받으므로 백엔드는 그대로다.
|
||||||
|
- **시각은 KST 로 해석**한다. 과거 시각은 거부하되
|
||||||
|
폼을 채우는 동안 시간이 흐른 경우를 위해 1분 여유를 둔다.
|
||||||
|
- **실행은 `app/modules/cafe24/worker.py`** — compose 서비스 `dbx-cafe24-worker` 가
|
||||||
|
`--loop 60` 으로 돈다. 웹 요청 안에서 기다리는 방식은 프록시 타임아웃·재기동에
|
||||||
|
무너지므로 별도 프로세스여야 한다.
|
||||||
|
- worker 는 `claim_due_schedule` 로 **한 건씩 `FOR UPDATE SKIP LOCKED`** 로 잠그고
|
||||||
|
PROCESSING 으로 바꾼 뒤 잠금을 푼다. worker 가 둘 떠 있어도 같은 예약을 두 번
|
||||||
|
적용하지 않고, 긴 API 호출 동안 DB 잠금을 쥐고 있지도 않는다.
|
||||||
|
- 적용 순서는 화면 편집과 같다: **현재값 재조회 → BACKUP revision → PUT → SUCCESS +
|
||||||
|
감사로그**(`actor=SCHEDULER`, `action=schedule_apply`). HTML 없이 진열/판매만 바꾸는
|
||||||
|
예약은 상세설명을 읽지도, 백업하지도 않는다.
|
||||||
|
- 실패는 `store.MAX_RETRY`(3) 안에서 1분 → 5분 → 15분 간격으로 재시도하고, 소진되면
|
||||||
|
`FAILED` 로 확정한다. 한 건의 오류가 worker 를 죽이지 않는다.
|
||||||
|
- **대기(PENDING) 상태만 취소**할 수 있다. 실행 중/완료된 예약은 건드리지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. OAuth 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
관리자 [카페24 연결]
|
||||||
|
↓ state 생성 → 세션 저장
|
||||||
|
GET /cafe24/system/oauth/start → 302 카페24 인증 페이지
|
||||||
|
↓ 사용자 승인
|
||||||
|
GET /cafe24/oauth/callback?code=&state=
|
||||||
|
↓ 세션 state 와 대조 (불일치 시 토큰 교환 거부 — CSRF 방어)
|
||||||
|
code → access/refresh token 교환
|
||||||
|
↓ Fernet 암호화
|
||||||
|
cafe24_oauth_tokens 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
- scope 는 `app/integrations/cafe24/config.py` 의 `PRODUCT_SCOPES` =
|
||||||
|
`mall.read_product`, `mall.write_product`.
|
||||||
|
주문관리 추가 시 `ORDER_SCOPES` 를 합쳐 넘기고, 카페24 개발자센터 앱에서도
|
||||||
|
권한을 추가한 뒤 **재인증**하면 된다.
|
||||||
|
- access token 은 만료 2분 전부터 자동 갱신된다. 갱신은 토큰 행을
|
||||||
|
`SELECT ... FOR UPDATE` 로 잠근 채 수행 — web 컨테이너와 worker 컨테이너가
|
||||||
|
동시에 refresh 해서 한쪽 토큰이 무효화되는 것을 막는다(카페24는 refresh
|
||||||
|
token 을 회전시킨다).
|
||||||
|
- refresh token 이 만료되면 자동 복구가 불가능하므로 화면에 "재연결 필요"를
|
||||||
|
표시한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3-1. 상세설명 API 사실 (실물 확인 결과 — 추측 금지)
|
||||||
|
|
||||||
|
운영 쇼핑몰(`miraskitchen`)에서 직접 확인한 내용이다. 문서에 없는 경로를
|
||||||
|
추측해서 쓰지 말 것.
|
||||||
|
|
||||||
|
- **`/admin/products/{no}/description` 서브리소스는 존재하지 않는다.**
|
||||||
|
호출하면 `No API found.` 가 온다. 상세설명은 **상품 리소스의 필드**다.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/products/{no} → description · mobile_description ·
|
||||||
|
separated_mobile_description
|
||||||
|
PUT /admin/products/{no} → {"request": {"description": "..."}}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **목록 API(`GET /admin/products`) 응답에는 `description` 이 없다.**
|
||||||
|
그래서 상세설명은 상품 1건씩 조회해야 하고, 목록 화면에 미리보기를 뿌리지
|
||||||
|
않는다(상품 87개 × 1호출 = 호출 제한 위험).
|
||||||
|
|
||||||
|
- **PC/모바일 상세설명이 분리되어 있다.** `separated_mobile_description`
|
||||||
|
(`'T'`/`'F'`) 이 분리 사용 여부다. `'F'`(미분리) 상품을 수정할 때는
|
||||||
|
`description` 과 `mobile_description` 을 같은 HTML 로 함께 맞춘다.
|
||||||
|
`'T'` 면 두 값을 따로 관리해야 한다.
|
||||||
|
|
||||||
|
- 그 밖에 상세 응답에만 있는 참고 필드: `translated_description`(다국어),
|
||||||
|
`summary_description`(요약설명), `simple_description`, `shop_no`(멀티쇼핑몰).
|
||||||
|
|
||||||
|
- **이미지 경로의 한글은 퍼센트 인코딩되어 저장된다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
src="/web/product/big/%EC%9A%A9%EA%B8%B0…(%ED%99%A9%ED%86%A0)_12.gif"
|
||||||
|
```
|
||||||
|
|
||||||
|
사람이 읽을 수 없으므로 화면에서는 `store.decode_html_urls` 로 풀어서 보여주고,
|
||||||
|
저장할 때 `store.encode_html_urls` 로 되돌린다. 두 함수는 서로의 역이며
|
||||||
|
**왕복이 보존된다**(편집하지 않고 적용해도 저장값이 바뀌지 않는다 — 테스트로 고정).
|
||||||
|
안전 규칙: 디코딩은 non-ASCII(`%80`~`%FF`)만, 인코딩은 URL 속성값 안만.
|
||||||
|
`%20`·`%3C` 를 풀거나 본문 한글을 인코딩하면 페이지가 깨진다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3-2. 편집·적용 규칙 (`POST /products/{no}/apply`)
|
||||||
|
|
||||||
|
이 순서를 절대 바꾸지 않는다.
|
||||||
|
|
||||||
|
0. 제출된 HTML 을 `encode_html_urls` → `format_html` 순으로 다듬는다(화면에서 본
|
||||||
|
정리된 소스가 그대로 저장된다).
|
||||||
|
1. **카페24에서 현재 HTML 을 다시 읽는다.** 로컬 DB 의 마지막 버전을 "지금
|
||||||
|
올라간 값"으로 가정하지 않는다(카페24 관리자에서 직접 고쳤을 수 있다).
|
||||||
|
2. 그 값으로 **BACKUP revision** 을 남긴다. 유일한 복구 수단이다.
|
||||||
|
3. **지문 대조** — 편집 화면을 열 때의 `fingerprint`(sha256 앞 32자)와 지금
|
||||||
|
카페24 값의 지문이 다르면 적용을 거부한다. 편집 중 남이 바꾼 내용을 조용히
|
||||||
|
덮어쓰는 것을 막는 낙관적 잠금이다.
|
||||||
|
4. 내용이 같으면 호출하지 않는다(불필요한 쓰기·API 호출 방지).
|
||||||
|
5. PUT 적용 → **MANUAL revision** + 감사로그(`apply_description`).
|
||||||
|
|
||||||
|
추가 규칙:
|
||||||
|
|
||||||
|
- 빈 내용은 거부한다(상세페이지 전체를 날리는 실수 방지).
|
||||||
|
- **PC/모바일을 구분하지 않는다.** `description` 과 `mobile_description` 에 항상 같은
|
||||||
|
HTML 을 쓴다(운영 방침). `separated_mobile_description` 값과 무관하며, 편집 화면에도
|
||||||
|
모바일 소스를 따로 보여주지 않는다 — 한쪽만 바뀌어 어긋나는 사고가 없어진다.
|
||||||
|
분리 사용 상품의 모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 **그 내용도 BACKUP
|
||||||
|
revision 으로 남긴다**(백업이 없으면 되찾을 방법이 없다).
|
||||||
|
- 실패해도 BACKUP 은 이미 남아 있으므로 오류 메시지에 버전 번호를 알려준다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 보안 규칙 (반드시 지킬 것)
|
||||||
|
|
||||||
|
- `client_secret`·토큰을 코드에 하드코딩하지 않는다. 전부 `.env`.
|
||||||
|
- **로그·예외 메시지·템플릿에 토큰/시크릿을 절대 출력하지 않는다.**
|
||||||
|
`cafe24_api_logs` 에도 Authorization 헤더를 기록하지 않는다.
|
||||||
|
- 토큰은 DB 에 Fernet 암호문으로만 저장한다(`CAFE24_TOKEN_SECRET`).
|
||||||
|
- 카페24 연결/해제는 `is_admin` 전용.
|
||||||
|
- OAuth 콜백은 세션 `state` 대조 후에만 code 를 교환한다.
|
||||||
|
- SQL 은 `%s` 플레이스홀더만 사용한다(문자열 조립 금지).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 설치 / 실행
|
||||||
|
|
||||||
|
### 5-1. 카페24 개발자센터 앱 등록 (사람이 해야 하는 일)
|
||||||
|
|
||||||
|
1. <https://developers.cafe24.com> 로그인 → 앱 생성
|
||||||
|
2. Redirect URI 를 `.env` 의 `CAFE24_REDIRECT_URI` 와 **정확히 동일하게** 등록
|
||||||
|
(운영: `https://dbx.no1king.freeddns.org/cafe24/oauth/callback`)
|
||||||
|
3. 권한(Scope)에 `mall.read_product`, `mall.write_product` 체크
|
||||||
|
4. 발급된 Client ID / Client Secret 을 `.env` 에 기입
|
||||||
|
|
||||||
|
### 5-2. DB 초기화 (superuser 로 1회)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
read -s -p "cafe24_app password: " APP_PWD; echo
|
||||||
|
docker exec -i postgres-db psql -U postgres \
|
||||||
|
-v app_password="$APP_PWD" \
|
||||||
|
< scripts/sql/cafe24_db_init.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5-3. .env
|
||||||
|
|
||||||
|
```
|
||||||
|
CAFE24_DB_URL=postgresql://cafe24_app:<APP_PWD>@postgres-db:5432/cafe24_db
|
||||||
|
CAFE24_MALL_ID=miraskitchen
|
||||||
|
CAFE24_CLIENT_ID=...
|
||||||
|
CAFE24_CLIENT_SECRET=...
|
||||||
|
CAFE24_REDIRECT_URI=https://dbx.no1king.freeddns.org/cafe24/oauth/callback
|
||||||
|
CAFE24_API_VERSION=2026-03-01
|
||||||
|
CAFE24_TOKEN_SECRET=<openssl rand -hex 32>
|
||||||
|
CAFE24_SHOP_URL=https://miras.co.kr # 선택 — 다이렉트 주소용 커스텀 도메인
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5-4. 재기동 + 권한 부여
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/www/main && docker compose up -d --build web cafe24-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
예약 기능을 쓰려면 마이그레이션 002 를 먼저 적용해야 한다(진열/판매 예약 컬럼).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_002_schedule_flags.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
worker 가 도는지 확인:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/www/main && docker compose logs --tail=20 cafe24-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
관리자 페이지(`/admin`)에서 직원에게 **카페24 상품관리** 권한을 부여한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 테스트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m app.modules.cafe24.tests.test_cafe24
|
||||||
|
```
|
||||||
|
|
||||||
|
DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노출(토큰 미유출),
|
||||||
|
재시도 예산, 예약 상태 전이를 검증한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 진행 상태
|
||||||
|
|
||||||
|
| Phase | 내용 | 상태 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 공통 Integration · cafe24_db · OAuth 연결 화면 | ✅ 완료 |
|
||||||
|
| 2 | 상품 목록·검색·현재 HTML 조회 | ✅ 완료 |
|
||||||
|
| 3 | 편집기 · 미리보기 · Diff · 초안 | ◐ 문법 강조 편집기 + 소스 정리 완료. 미리보기·Diff·초안 예정 |
|
||||||
|
| 4 | 즉시 적용 · BACKUP · Revision · 감사로그 | ✅ 완료 |
|
||||||
|
| 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | ✅ 완료 |
|
||||||
|
| 6 | 자동 종료/복원 · 롤백 | ✖ 되돌리기는 쓰지 않기로 결정. 버전 선택 복원만 남음 |
|
||||||
|
| 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | ✖ 일괄수정은 사용하지 않기로 제거. 필요해지면 다시 논의 |
|
||||||
|
|
||||||
|
Phase 5 의 worker 는 `app/modules/cafe24/worker.py` 에 둔다 — `Dockerfile` 이
|
||||||
|
`COPY app/ ./app/` 만 하므로 `scripts/` 에 두면 이미지에 포함되지 않는다.
|
||||||
|
`docker-compose.yml` 에 같은 이미지로 `dbx-cafe24-worker` 서비스를 추가해
|
||||||
|
`python -m app.modules.cafe24.worker --loop 60` 으로 돌린다.
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
| `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 |
|
| `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 |
|
||||||
| `cupang_db` | 쿠팡 밀크런 출고 묶음 / 출고 라인 / 입고센터 / 박스 입수량 규칙 |
|
| `cupang_db` | 쿠팡 밀크런 출고 묶음 / 출고 라인 / 입고센터 / 박스 입수량 규칙 |
|
||||||
| `malaysia_stock_db` | 말레이시아 창고 재고관리 — 창고/아이템/세트 BOM/입출고 이력/일일 재고조사 |
|
| `malaysia_stock_db` | 말레이시아 창고 재고관리 — 창고/아이템/세트 BOM/입출고 이력/일일 재고조사 |
|
||||||
|
| `cafe24_db` | 카페24 연동 — OAuth 토큰/상품 캐시/상세페이지 버전/예약/감사·API 로그 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -290,6 +291,46 @@ cd /opt/www/main && docker compose up -d --build
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## cafe24_db 스키마 / 초기화
|
||||||
|
|
||||||
|
DDL: `scripts/sql/cafe24_db_init.sql` (멱등). DB·역할(`cafe24_app`)·테이블·인덱스·트리거를 한 번에 생성. **JSON 폴백 없음** — `CAFE24_DB_URL` 미설정 시 모듈이 "설정 필요" 안내만 표시.
|
||||||
|
|
||||||
|
테이블:
|
||||||
|
|
||||||
|
| 테이블 | 용도 |
|
||||||
|
| --- | --- |
|
||||||
|
| `cafe24_oauth_tokens` | 쇼핑몰별 OAuth 토큰(`mall_id` UNIQUE). access/refresh 는 **Fernet 암호문**으로 저장. 상품관리 + 향후 주문관리가 공유 |
|
||||||
|
| `cafe24_products` | 상품 캐시(`product_no` UNIQUE). 목록/검색 속도용이며 source of truth 는 언제나 카페24 |
|
||||||
|
| `cafe24_product_revisions` | 상세페이지 HTML 버전(append-only). `revision_type` SYNC/DRAFT/**BACKUP**/MANUAL/SCHEDULED/ROLLBACK |
|
||||||
|
| `cafe24_product_schedules` | 예약 작업. `status` PENDING/PROCESSING/SUCCESS/FAILED/CANCELLED, 재시도·자동종료·복원 대상 포함 |
|
||||||
|
| `cafe24_audit_logs` | 누가 무엇을 바꿨나. worker 수행분은 `actor='SCHEDULER'` |
|
||||||
|
| `cafe24_api_logs` | 카페24 API 호출 기록. **토큰/Authorization/client_secret 미기록** |
|
||||||
|
|
||||||
|
핵심 규칙:
|
||||||
|
|
||||||
|
- 카페24에 쓰기 직전 **반드시 현재 HTML 을 다시 조회해 `BACKUP` revision 으로 저장**한다. 로컬 DB 의 마지막 값을 현재값으로 가정하지 않는다.
|
||||||
|
- 예약의 `restore_revision_id` 는 **예약 실행 순간** 만든 BACKUP 을 가리킨다(예약 생성 시점 값이 아님).
|
||||||
|
- 일괄 예약은 상품 1건당 1행 + 공통 `parent_job_id` — 한 상품 실패가 나머지를 막지 않는다.
|
||||||
|
- 토큰 암호화 키는 `.env` 의 `CAFE24_TOKEN_SECRET`. **값을 바꾸면 기존 토큰을 복호화할 수 없어 카페24 재연결이 필요하다.**
|
||||||
|
|
||||||
|
### 운영 서버 초기화 (1회, 사용자 승인 후)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
read -s -p "cafe24_app password: " APP_PWD; echo
|
||||||
|
docker exec -i postgres-db psql -U postgres \
|
||||||
|
-v app_password="$APP_PWD" \
|
||||||
|
< scripts/sql/cafe24_db_init.sql
|
||||||
|
# main-app .env 에 추가:
|
||||||
|
# CAFE24_DB_URL=postgresql://cafe24_app:<APP_PWD>@postgres-db:5432/cafe24_db
|
||||||
|
# CAFE24_MALL_ID / CAFE24_CLIENT_ID / CAFE24_CLIENT_SECRET / CAFE24_REDIRECT_URI
|
||||||
|
# CAFE24_TOKEN_SECRET=$(openssl rand -hex 32)
|
||||||
|
cd /opt/www/main && docker compose up -d --build web
|
||||||
|
```
|
||||||
|
|
||||||
|
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. 상세는 `docs/CAFE24_MODULE.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 백업 / 복구 (안전 절차)
|
## 백업 / 복구 (안전 절차)
|
||||||
|
|
||||||
### 백업
|
### 백업
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ python-multipart>=0.0.20
|
|||||||
openpyxl>=3.1
|
openpyxl>=3.1
|
||||||
pdfplumber>=0.11
|
pdfplumber>=0.11
|
||||||
pillow>=10.0
|
pillow>=10.0
|
||||||
|
# 카페24 OAuth 토큰 암호화 저장(Fernet) — app/integrations/cafe24/crypto.py
|
||||||
|
cryptography>=42.0
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- cafe24_db 002 — 예약에 진열/판매 상태 변경 추가
|
||||||
|
-- =====================================================================
|
||||||
|
-- 멱등(idempotent): 여러 번 실행해도 안전. DROP/TRUNCATE 없음.
|
||||||
|
--
|
||||||
|
-- 실행:
|
||||||
|
-- docker exec -i postgres-db psql -U postgres -d cafe24_db \
|
||||||
|
-- < scripts/sql/cafe24_db_002_schedule_flags.sql
|
||||||
|
--
|
||||||
|
-- 배경:
|
||||||
|
-- 예약 적용에서 상세페이지 HTML 뿐 아니라 진열/판매 상태도 함께 바꿀 수 있어야
|
||||||
|
-- 한다("지정 시각에 공개" 같은 운영 시나리오). 세 값 모두 "건드리지 않음"이
|
||||||
|
-- 가능해야 하므로 NULL 을 허용하는 BOOLEAN 으로 둔다.
|
||||||
|
-- set_display = NULL → 진열 상태 그대로
|
||||||
|
-- set_selling = NULL → 판매 상태 그대로
|
||||||
|
-- revision_id = NULL → 상세페이지 HTML 은 바꾸지 않음(상태만 변경)
|
||||||
|
--
|
||||||
|
-- 되돌리기(자동 복원)는 쓰지 않기로 했다. end_at / end_action /
|
||||||
|
-- end_revision_id / restore_revision_id 컬럼은 남겨두지만 사용하지 않는다
|
||||||
|
-- (컬럼 삭제는 파괴적이라 하지 않는다).
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE cafe24_product_schedules
|
||||||
|
ADD COLUMN IF NOT EXISTS set_display BOOLEAN,
|
||||||
|
ADD COLUMN IF NOT EXISTS set_selling BOOLEAN;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN cafe24_product_schedules.set_display IS
|
||||||
|
'예약 시각에 적용할 진열 상태. NULL 이면 변경하지 않음';
|
||||||
|
COMMENT ON COLUMN cafe24_product_schedules.set_selling IS
|
||||||
|
'예약 시각에 적용할 판매 상태. NULL 이면 변경하지 않음';
|
||||||
|
COMMENT ON COLUMN cafe24_product_schedules.revision_id IS
|
||||||
|
'적용할 상세페이지 버전. NULL 이면 HTML 은 바꾸지 않고 진열/판매만 변경';
|
||||||
|
|
||||||
|
-- 아무것도 하지 않는 예약은 만들 수 없게 한다(실수 방지).
|
||||||
|
ALTER TABLE cafe24_product_schedules
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_cafe24_schedule_has_action;
|
||||||
|
ALTER TABLE cafe24_product_schedules
|
||||||
|
ADD CONSTRAINT chk_cafe24_schedule_has_action CHECK (
|
||||||
|
revision_id IS NOT NULL OR set_display IS NOT NULL OR set_selling IS NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT 'cafe24_db 002 applied' AS status;
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- cafe24_db 초기화 스크립트 (PostgreSQL) — 카페24 상품 상세페이지 관리
|
||||||
|
-- =====================================================================
|
||||||
|
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
|
||||||
|
--
|
||||||
|
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
|
||||||
|
--
|
||||||
|
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
|
||||||
|
--
|
||||||
|
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
|
||||||
|
-- read -s -p "cafe24_app password: " APP_PWD; echo
|
||||||
|
-- docker exec -i postgres-db psql -U postgres \
|
||||||
|
-- -v app_password="$APP_PWD" \
|
||||||
|
-- < scripts/sql/cafe24_db_init.sql
|
||||||
|
--
|
||||||
|
-- 2) main-app .env 에 연결 정보 등록
|
||||||
|
-- CAFE24_DB_URL=postgresql://cafe24_app:<APP_PWD>@postgres-db:5432/cafe24_db
|
||||||
|
--
|
||||||
|
-- 3) main-app 재기동
|
||||||
|
-- cd /opt/www/main && docker compose up -d --build web
|
||||||
|
--
|
||||||
|
-- 주의:
|
||||||
|
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
|
||||||
|
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
|
||||||
|
-- - OAuth 토큰(access/refresh)은 애플리케이션에서 Fernet 으로 암호화한 뒤
|
||||||
|
-- 저장한다. 키는 .env 의 CAFE24_TOKEN_SECRET. 이 DB 에 평문 토큰은 없다.
|
||||||
|
-- - cafe24_api_logs 에는 Authorization 헤더/토큰/client_secret 을 절대
|
||||||
|
-- 기록하지 않는다(엔드포인트·상태코드·소요시간·오류메시지만).
|
||||||
|
-- - 향후 카페24 주문관리 모듈도 이 DB(특히 cafe24_oauth_tokens)를 재사용한다.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
-- DB 가 없을 때만 생성
|
||||||
|
SELECT 'CREATE DATABASE cafe24_db ENCODING ''UTF8'' TEMPLATE template0'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cafe24_db')
|
||||||
|
\gexec
|
||||||
|
|
||||||
|
-- 앱 전용 로그인 역할
|
||||||
|
SELECT 'CREATE ROLE cafe24_app LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'cafe24_app')
|
||||||
|
\gexec
|
||||||
|
|
||||||
|
-- 항상 최신 비밀번호로 동기화
|
||||||
|
SELECT 'ALTER ROLE cafe24_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||||
|
\gexec
|
||||||
|
|
||||||
|
GRANT CONNECT ON DATABASE cafe24_db TO cafe24_app;
|
||||||
|
|
||||||
|
-- cafe24_db 컨텍스트로 전환
|
||||||
|
\connect cafe24_db
|
||||||
|
|
||||||
|
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
|
||||||
|
CREATE OR REPLACE FUNCTION cafe24_set_updated_at() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at := now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- 1) OAuth 토큰 (쇼핑몰 1개당 1행)
|
||||||
|
-- access_token / refresh_token 은 Fernet 암호문(TEXT)으로 저장한다.
|
||||||
|
-- 상품관리 + 향후 주문관리가 공유한다.
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS cafe24_oauth_tokens (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
mall_id TEXT NOT NULL UNIQUE,
|
||||||
|
access_token TEXT NOT NULL DEFAULT '',
|
||||||
|
refresh_token TEXT NOT NULL DEFAULT '',
|
||||||
|
access_token_expires_at TIMESTAMPTZ,
|
||||||
|
refresh_token_expires_at TIMESTAMPTZ,
|
||||||
|
scopes TEXT NOT NULL DEFAULT '',
|
||||||
|
last_refreshed_at TIMESTAMPTZ,
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
connected_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_cafe24_oauth_tokens_updated ON cafe24_oauth_tokens;
|
||||||
|
CREATE TRIGGER trg_cafe24_oauth_tokens_updated
|
||||||
|
BEFORE UPDATE ON cafe24_oauth_tokens
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at();
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- 2) 상품 캐시 (source of truth 는 언제나 Cafe24. 목록/검색 속도용)
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS cafe24_products (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
product_no BIGINT NOT NULL UNIQUE,
|
||||||
|
product_code TEXT NOT NULL DEFAULT '',
|
||||||
|
product_name TEXT NOT NULL DEFAULT '',
|
||||||
|
display BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
selling BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_synced_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_cafe24_products_updated ON cafe24_products;
|
||||||
|
CREATE TRIGGER trg_cafe24_products_updated
|
||||||
|
BEFORE UPDATE ON cafe24_products
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at();
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- 3) 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다)
|
||||||
|
-- revision_type:
|
||||||
|
-- SYNC Cafe24 에서 읽어온 현재값 스냅샷
|
||||||
|
-- DRAFT 저장만 한 초안(미적용)
|
||||||
|
-- BACKUP Cafe24 에 쓰기 직전 자동 백업 ← 복원 기준
|
||||||
|
-- MANUAL 즉시 적용한 내용
|
||||||
|
-- SCHEDULED 예약으로 적용한 내용
|
||||||
|
-- ROLLBACK 과거 버전을 되돌린 내용
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS cafe24_product_revisions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
product_no BIGINT NOT NULL,
|
||||||
|
html_content TEXT NOT NULL DEFAULT '',
|
||||||
|
revision_type TEXT NOT NULL DEFAULT 'DRAFT',
|
||||||
|
memo TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT chk_cafe24_revision_type CHECK (
|
||||||
|
revision_type IN ('SYNC','DRAFT','BACKUP','MANUAL','SCHEDULED','ROLLBACK')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cafe24_revisions_product
|
||||||
|
ON cafe24_product_revisions (product_no, created_at DESC, id DESC);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- 4) 예약 작업
|
||||||
|
-- 한 상품 = 한 행. 일괄 예약은 parent_job_id 로 묶되 행은 개별이므로
|
||||||
|
-- 한 상품 실패가 나머지를 막지 않는다.
|
||||||
|
-- end_at/end_action: 프로모션 종료 후 자동 복원용.
|
||||||
|
-- restore = 적용 직전 BACKUP(restore_revision_id)으로 되돌림
|
||||||
|
-- revision = end_revision_id 를 적용
|
||||||
|
-- restore_revision_id 는 예약 "실행 순간" Cafe24 에서 다시 읽어 만든
|
||||||
|
-- BACKUP revision 을 가리킨다(예약 생성 시점 값이 아님).
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS cafe24_product_schedules (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
product_no BIGINT NOT NULL,
|
||||||
|
revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE RESTRICT,
|
||||||
|
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'PENDING',
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_retry_at TIMESTAMPTZ,
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
restore_revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE SET NULL,
|
||||||
|
end_at TIMESTAMPTZ,
|
||||||
|
end_action TEXT NOT NULL DEFAULT '',
|
||||||
|
end_revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE SET NULL,
|
||||||
|
parent_job_id TEXT NOT NULL DEFAULT '',
|
||||||
|
memo TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT chk_cafe24_schedule_status CHECK (
|
||||||
|
status IN ('PENDING','PROCESSING','SUCCESS','FAILED','CANCELLED')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_cafe24_schedule_end_action CHECK (
|
||||||
|
end_action IN ('','restore','revision')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- worker 의 due 조회 인덱스 (PENDING + 시간순)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_due
|
||||||
|
ON cafe24_product_schedules (scheduled_at)
|
||||||
|
WHERE status = 'PENDING';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_product
|
||||||
|
ON cafe24_product_schedules (product_no, scheduled_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_parent
|
||||||
|
ON cafe24_product_schedules (parent_job_id);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_cafe24_schedules_updated ON cafe24_product_schedules;
|
||||||
|
CREATE TRIGGER trg_cafe24_schedules_updated
|
||||||
|
BEFORE UPDATE ON cafe24_product_schedules
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at();
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- 5) 작업 감사 로그 (누가 무엇을 바꿨나)
|
||||||
|
-- worker 가 수행한 작업은 actor='SCHEDULER'.
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS cafe24_audit_logs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
actor TEXT NOT NULL DEFAULT '',
|
||||||
|
action TEXT NOT NULL DEFAULT '',
|
||||||
|
product_no BIGINT,
|
||||||
|
revision_id BIGINT,
|
||||||
|
schedule_id BIGINT,
|
||||||
|
result TEXT NOT NULL DEFAULT '',
|
||||||
|
detail TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cafe24_audit_created ON cafe24_audit_logs (created_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cafe24_audit_product ON cafe24_audit_logs (product_no, created_at DESC);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- 6) Cafe24 API 호출 로그 (실패 분석용 최소 정보)
|
||||||
|
-- ⚠️ Authorization 헤더 / access_token / refresh_token / client_secret 은
|
||||||
|
-- 절대 저장하지 않는다.
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS cafe24_api_logs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
endpoint TEXT NOT NULL DEFAULT '',
|
||||||
|
method TEXT NOT NULL DEFAULT '',
|
||||||
|
product_no BIGINT,
|
||||||
|
http_status INTEGER,
|
||||||
|
result TEXT NOT NULL DEFAULT '',
|
||||||
|
error_message TEXT NOT NULL DEFAULT '',
|
||||||
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cafe24_api_logs_created ON cafe24_api_logs (created_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cafe24_api_logs_status ON cafe24_api_logs (http_status);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- 7) 권한 (cafe24_app: CRUD only, DDL 없음)
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
GRANT USAGE ON SCHEMA public TO cafe24_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON
|
||||||
|
cafe24_oauth_tokens, cafe24_products, cafe24_product_revisions,
|
||||||
|
cafe24_product_schedules, cafe24_audit_logs, cafe24_api_logs
|
||||||
|
TO cafe24_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cafe24_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO cafe24_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO cafe24_app;
|
||||||
|
|
||||||
|
SELECT 'cafe24_db ready' AS status;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- 쿠팡 밀크런 — 박스 입수량 규칙 초기 입력 (cupang_db)
|
||||||
|
--
|
||||||
|
-- 실행:
|
||||||
|
-- docker exec -i postgres-db psql -U postgres -d cupang_db \
|
||||||
|
-- < /opt/www/main/scripts/sql/cupang_box_rules_seed.sql
|
||||||
|
--
|
||||||
|
-- 제품코드는 cupang_products 카탈로그 기준(2026-08-28 확인).
|
||||||
|
-- 같은 product_code 는 덮어쓴다(ON CONFLICT DO UPDATE).
|
||||||
|
-- 피킹비는 전용 컬럼이 없어 memo 에 기록한다.
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
INSERT INTO cupang_box_rules
|
||||||
|
(product_code, product_name_snapshot, box_name, units_per_box, memo, active)
|
||||||
|
VALUES
|
||||||
|
('MS-1001', '미라네 1호 세트', '쿠팡박스', 8, '피킹비 750원 · 2호', TRUE),
|
||||||
|
('MS-1002', '미라네 2호 세트', '쿠팡박스', 8, '피킹비 1100원 · 2호', TRUE),
|
||||||
|
('MS-1003', '미라네 3호 세트', '쿠팡박스', 8, '피킹비 1200원 · 2호', TRUE),
|
||||||
|
('MS-1004', '미라네 4호 세트', '쿠팡박스', 8, '피킹비 1000원 · 2호', TRUE),
|
||||||
|
('MS-1005', '미라네 5호 세트', '쿠팡박스', 8, '피킹비 650원 · 2호', TRUE),
|
||||||
|
('MS-1006', '미라네 6호 세트', '쿠팡박스', 8, '피킹비 750원 · 2호', TRUE),
|
||||||
|
('MS-1007', '미라네 7호 세트', '쿠팡박스', 8, '피킹비 850원 · 2호', TRUE),
|
||||||
|
('MS-9812', '황토 김치통 1호세트', '쿠팡박스', 8, '피킹비 750원 · 2호', TRUE),
|
||||||
|
('MS-9813', '황토 김치통 2호세트', '쿠팡박스', 8, '피킹비 650원 · 2호', TRUE),
|
||||||
|
('MS-9814', '황토 김치통 3호세트', '쿠팡박스', 8, '피킹비 750원 · 2호', TRUE),
|
||||||
|
('MS-9815', '황토 김치통 4호세트', '쿠팡박스', 2, '피킹비 850원 · 3호', TRUE),
|
||||||
|
('MS-9816', '황토 김치통 5호세트', '쿠팡박스', 2, '피킹비 900원 · 3호', TRUE),
|
||||||
|
('MS-9817', '황토 김치통 6호세트', '쿠팡박스', 1, '피킹비 1000원 · 6호', TRUE),
|
||||||
|
('MS-9818', '황토 김치통 7호세트', '쿠팡박스', 1, '피킹비 1100원 · 6호', TRUE)
|
||||||
|
ON CONFLICT (product_code) DO UPDATE
|
||||||
|
SET product_name_snapshot = EXCLUDED.product_name_snapshot,
|
||||||
|
box_name = EXCLUDED.box_name,
|
||||||
|
units_per_box = EXCLUDED.units_per_box,
|
||||||
|
memo = EXCLUDED.memo,
|
||||||
|
active = TRUE;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- 결과 확인
|
||||||
|
SELECT product_code, product_name_snapshot, box_name, units_per_box, memo
|
||||||
|
FROM cupang_box_rules
|
||||||
|
ORDER BY product_code;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- 쿠팡 밀크런 — 박스 입수량 규칙 2차 입력 (cupang_db)
|
||||||
|
-- 미라클통/미라클백/빠져락/멀티/점보 19건
|
||||||
|
--
|
||||||
|
-- 실행:
|
||||||
|
-- docker exec -i postgres-db psql -U postgres -d cupang_db \
|
||||||
|
-- < /opt/www/main/scripts/sql/cupang_box_rules_seed2.sql
|
||||||
|
--
|
||||||
|
-- 제품코드는 cupang_products 카탈로그에서 제품명으로 매칭한다.
|
||||||
|
-- (공백 무시 + 대소문자 무시 비교)
|
||||||
|
-- 카탈로그에 없는 제품은 입력되지 않고 마지막 SELECT 에 출력된다.
|
||||||
|
-- → 제품명 설정 화면의 "수기 추가"로 제품코드를 등록한 뒤 이 파일을 다시 실행.
|
||||||
|
-- 같은 product_code 는 덮어쓴다(ON CONFLICT DO UPDATE).
|
||||||
|
-- 피킹비는 전용 컬럼이 없어 memo 에 기록한다.
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- box_no: 원자료의 박스 호수. 박스명은 '쿠팡박스' 로 통일하고 호수는 memo 에 남긴다.
|
||||||
|
CREATE TEMP TABLE _seed2 (
|
||||||
|
product_name TEXT,
|
||||||
|
picking_fee INTEGER,
|
||||||
|
units_per_box INTEGER,
|
||||||
|
box_no TEXT
|
||||||
|
) ON COMMIT DROP;
|
||||||
|
|
||||||
|
INSERT INTO _seed2 (product_name, picking_fee, units_per_box, box_no) VALUES
|
||||||
|
('미라클통 550x4', 700, 40, '2호'),
|
||||||
|
('미라클통 550x6', 700, 30, '2호'),
|
||||||
|
('미라클통 750x5', 700, 30, '2호'),
|
||||||
|
('미라클통 950x4', 700, 24, '2호'),
|
||||||
|
('미라클통 950x6', 700, 18, '2호'),
|
||||||
|
('미라클통 1800x4', 700, 16, '2호'),
|
||||||
|
('미라클통 1800x6', 700, 14, '2호'),
|
||||||
|
('미라클통 2200x4', 700, 14, '2호'),
|
||||||
|
('미라클통 2200x6', 700, 10, '2호'),
|
||||||
|
('미라클통 3200x4', 700, 10, '2호'),
|
||||||
|
('빠져락 x1', 500, 35, '2호'),
|
||||||
|
('빠져락 x3', 580, 8, '2호'),
|
||||||
|
('빠져락 x5', 780, 8, '2호'),
|
||||||
|
('미라클백 2.5L', 500, 30, '5호'),
|
||||||
|
('미라클백 6.0L', 500, 23, '6호'),
|
||||||
|
('멀티 5,000ml x2', 700, 8, '2호'),
|
||||||
|
('멀티 5,000ml x4', 680, 2, '3호'),
|
||||||
|
('점보 7,000ml x2', 700, 6, '2호'),
|
||||||
|
('점보 황토 7,000ml x2', 700, 6, '2호');
|
||||||
|
|
||||||
|
INSERT INTO cupang_box_rules
|
||||||
|
(product_code, product_name_snapshot, box_name, units_per_box, memo, active)
|
||||||
|
SELECT p.product_code,
|
||||||
|
p.product_name,
|
||||||
|
'쿠팡박스',
|
||||||
|
s.units_per_box,
|
||||||
|
'피킹비 ' || s.picking_fee || '원 · ' || s.box_no,
|
||||||
|
TRUE
|
||||||
|
FROM _seed2 s
|
||||||
|
JOIN cupang_products p
|
||||||
|
ON lower(regexp_replace(p.product_name, '\s', '', 'g'))
|
||||||
|
= lower(regexp_replace(s.product_name, '\s', '', 'g'))
|
||||||
|
ON CONFLICT (product_code) DO UPDATE
|
||||||
|
SET product_name_snapshot = EXCLUDED.product_name_snapshot,
|
||||||
|
box_name = EXCLUDED.box_name,
|
||||||
|
units_per_box = EXCLUDED.units_per_box,
|
||||||
|
memo = EXCLUDED.memo,
|
||||||
|
active = TRUE;
|
||||||
|
|
||||||
|
-- 카탈로그에 없어 입력되지 않은 제품 (수기 추가 후 재실행 대상)
|
||||||
|
SELECT s.product_name AS "카탈로그_미등록_제품명",
|
||||||
|
s.units_per_box AS "입수량",
|
||||||
|
s.box_no AS "박스호수"
|
||||||
|
FROM _seed2 s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM cupang_products p
|
||||||
|
WHERE lower(regexp_replace(p.product_name, '\s', '', 'g'))
|
||||||
|
= lower(regexp_replace(s.product_name, '\s', '', 'g'))
|
||||||
|
)
|
||||||
|
ORDER BY s.product_name;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- 결과 확인
|
||||||
|
SELECT product_code, product_name_snapshot, box_name, units_per_box, memo
|
||||||
|
FROM cupang_box_rules
|
||||||
|
ORDER BY product_code;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- 쿠팡 밀크런 — 박스 입수량 규칙의 박스명을 '쿠팡박스' 로 통일 (cupang_db)
|
||||||
|
--
|
||||||
|
-- 실행 전 반드시 백업:
|
||||||
|
-- docker exec postgres-db pg_dump -U postgres -d cupang_db -t cupang_box_rules \
|
||||||
|
-- > ~/backup/cupang_box_rules_$(date +%F_%H%M).sql
|
||||||
|
--
|
||||||
|
-- 실행:
|
||||||
|
-- docker exec -i postgres-db psql -U postgres -d cupang_db \
|
||||||
|
-- < /opt/www/main/scripts/sql/cupang_box_rules_unify_box_name.sql
|
||||||
|
--
|
||||||
|
-- 주의: 박스명은 "자투리 혼합 박스" 계산에서 같은 박스끼리만 섞는 기준이다.
|
||||||
|
-- 통일 후에는 2호/3호/6호 자투리가 한 박스에 섞여 계산된다.
|
||||||
|
-- 기존 호수(2호/3호/6호)는 없어지지 않게 memo 뒤에 옮겨 적는다.
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 변경 전 상태
|
||||||
|
SELECT box_name AS "변경전_박스명", count(*) AS "건수"
|
||||||
|
FROM cupang_box_rules
|
||||||
|
GROUP BY box_name
|
||||||
|
ORDER BY box_name;
|
||||||
|
|
||||||
|
UPDATE cupang_box_rules
|
||||||
|
SET memo = btrim(
|
||||||
|
coalesce(memo, '')
|
||||||
|
|| CASE WHEN coalesce(memo, '') = '' THEN '' ELSE ' · ' END
|
||||||
|
|| regexp_replace(box_name, '^쿠팡\s*', '')
|
||||||
|
),
|
||||||
|
box_name = '쿠팡박스'
|
||||||
|
WHERE box_name IS DISTINCT FROM '쿠팡박스';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- 변경 후 확인 (박스명이 '쿠팡박스' 한 줄이어야 정상)
|
||||||
|
SELECT box_name AS "변경후_박스명", count(*) AS "건수"
|
||||||
|
FROM cupang_box_rules
|
||||||
|
GROUP BY box_name
|
||||||
|
ORDER BY box_name;
|
||||||
|
|
||||||
|
SELECT product_code, product_name_snapshot, box_name, units_per_box, memo
|
||||||
|
FROM cupang_box_rules
|
||||||
|
ORDER BY product_code;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- itemcode_db: itemcode_ro 읽기 권한 재발 방지 (이벤트 트리거)
|
||||||
|
--
|
||||||
|
-- 증상: 말레이시아/쿠팡/dispatch 에서 세트 BOM "누락"·콤보 미분해·
|
||||||
|
-- 사방넷 출고 A열 빈칸. 진짜 원인은 데이터가 아니라 권한 —
|
||||||
|
-- "permission denied for table set_components(또는 single_items,
|
||||||
|
-- set_items)". itemcode_db 를 재import/재생성하면 itemcode_ro
|
||||||
|
-- GRANT 가 통째로 소실돼 반복 재발(2026-06-16/22/23/26/30, 07-01).
|
||||||
|
--
|
||||||
|
-- main-app 은 itemcode_db 를 읽기 전용(itemcode_ro)으로만 쓴다.
|
||||||
|
-- import 절차는 다른 앱(상품코드 마스터)에 있어 이 저장소가 못 건드림.
|
||||||
|
-- 그래서 import 절차와 무관하게, 테이블이 생길 때마다 postgres 가
|
||||||
|
-- 자동으로 itemcode_ro 에 SELECT 를 부여하도록 이벤트 트리거를 건다.
|
||||||
|
-- pg_restore 로 테이블이 drop→recreate 돼도 트리거가 즉시 재부여.
|
||||||
|
--
|
||||||
|
-- 실행(서버, superuser):
|
||||||
|
-- docker exec -i postgres-db psql -U postgres -d itemcode_db \
|
||||||
|
-- -f - < scripts/sql/itemcode_db_grant_ro_autotrigger.sql
|
||||||
|
-- 또는 파일 내용을 heredoc 으로 붙여넣기.
|
||||||
|
--
|
||||||
|
-- ⚠ 이벤트 트리거는 "테이블 drop→recreate"(pg_restore --clean, TRUNCATE
|
||||||
|
-- 아님) 케이스를 자동 커버. 만약 import 가 DROP DATABASE 후 재생성이면
|
||||||
|
-- 트리거 자체도 사라지므로, DB 재생성 직후 이 스크립트를 1회 다시 실행.
|
||||||
|
--
|
||||||
|
-- superuser(postgres)로 실행. 멱등.
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- 1) 지금 존재하는 테이블에 즉시 부여 (현재고 복구)
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA public TO itemcode_ro;
|
||||||
|
|
||||||
|
-- 2) 소유자(king) 기준 default privileges — king 이 만드는 신규 테이블 자동
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE king IN SCHEMA public
|
||||||
|
GRANT SELECT ON TABLES TO itemcode_ro;
|
||||||
|
|
||||||
|
-- 3) 이벤트 트리거 — 소유자·생성 방식 무관하게 CREATE TABLE 마다 자동 GRANT
|
||||||
|
CREATE OR REPLACE FUNCTION public.grant_select_to_itemcode_ro()
|
||||||
|
RETURNS event_trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
obj record;
|
||||||
|
BEGIN
|
||||||
|
FOR obj IN
|
||||||
|
SELECT object_identity
|
||||||
|
FROM pg_event_trigger_ddl_commands()
|
||||||
|
WHERE command_tag = 'CREATE TABLE'
|
||||||
|
AND schema_name = 'public'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('GRANT SELECT ON %s TO itemcode_ro', obj.object_identity);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP EVENT TRIGGER IF EXISTS trg_grant_select_to_itemcode_ro;
|
||||||
|
CREATE EVENT TRIGGER trg_grant_select_to_itemcode_ro
|
||||||
|
ON ddl_command_end
|
||||||
|
WHEN TAG IN ('CREATE TABLE')
|
||||||
|
EXECUTE FUNCTION public.grant_select_to_itemcode_ro();
|
||||||
|
|
||||||
|
-- 확인:
|
||||||
|
-- \dp set_components → itemcode_ro=r/king 표시되면 OK
|
||||||
|
-- \dy → trg_grant_select_to_itemcode_ro 등록 확인
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 박스 계산 화면 UI 테스트 (jsdom)
|
||||||
|
|
||||||
|
`app/modules/cupang/templates/cupang/box_calc.html` 의 드래그 분배·수량 대화상자·
|
||||||
|
잔여 계산을 브라우저 없이 검증한다. 서버 API 응답은 고정값으로 대체한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 템플릿을 HTML 로 렌더 (프로젝트 루트에서)
|
||||||
|
python tests/js/render_box_calc.py # → tests/js/.out/box_calc_render.html
|
||||||
|
|
||||||
|
# 2) jsdom 설치 후 실행
|
||||||
|
npm install jsdom
|
||||||
|
node tests/js/box_calc_ui.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
성공하면 마지막 줄에 `ALL PASS`, 실패하면 실패한 항목명이 출력되고 종료코드 1.
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// 박스 계산 화면(cupang/box_calc.html) UI 테스트 — jsdom.
|
||||||
|
// 실행 전 `python tests/js/render_box_calc.py` 로 템플릿을 렌더해 둔다.
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { JSDOM } = require("jsdom");
|
||||||
|
|
||||||
|
const RENDER = path.join(__dirname, ".out", "box_calc_render.html");
|
||||||
|
const html = fs.readFileSync(RENDER, "utf8");
|
||||||
|
|
||||||
|
// 서버 응답 고정: 미라네 1호 18개(2박스+자투리2), 미라네 4호 12개(1박스+자투리4)
|
||||||
|
const apiResponse = {
|
||||||
|
results: [
|
||||||
|
{product_code:"MS-1001", product_name:"미라네 1호 세트", box_name:"쿠팡박스", units_per_box:8,
|
||||||
|
quantity:18, configured:true, full_boxes:2, remainder_units:2, required_boxes:3},
|
||||||
|
{product_code:"MS-1004", product_name:"미라네 4호 세트", box_name:"쿠팡박스", units_per_box:8,
|
||||||
|
quantity:12, configured:true, full_boxes:1, remainder_units:4, required_boxes:2},
|
||||||
|
],
|
||||||
|
totals: [],
|
||||||
|
mixes: [{box_name:"쿠팡박스", box_count:1, leftover_units:6, boxes:[
|
||||||
|
{items:[{product_code:"MS-1004",product_name:"미라네 4호 세트",quantity:4},
|
||||||
|
{product_code:"MS-1001",product_name:"미라네 1호 세트",quantity:2}], fill_percent:75.0}]}],
|
||||||
|
grand_total_boxes: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dom = new JSDOM(html, { runScripts: "dangerously", pretendToBeVisual: true });
|
||||||
|
const w = dom.window, d = w.document;
|
||||||
|
w.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve(apiResponse) });
|
||||||
|
|
||||||
|
const fails = [];
|
||||||
|
function check(name, cond, extra) {
|
||||||
|
if (cond) console.log("PASS " + name);
|
||||||
|
else { console.log("FAIL " + name + (extra ? " → " + extra : "")); fails.push(name); }
|
||||||
|
}
|
||||||
|
const $ = (s) => d.querySelector(s);
|
||||||
|
const $$ = (s) => Array.from(d.querySelectorAll(s));
|
||||||
|
const text = (s) => ($(s) ? $(s).textContent.replace(/\s+/g, " ").trim() : "<none>");
|
||||||
|
const fire = (el, type) => el.dispatchEvent(new w.Event(type, { bubbles: true }));
|
||||||
|
|
||||||
|
// 마우스 드래그 시뮬레이션.
|
||||||
|
// jsdom 은 레이아웃이 없어 elementFromPoint 가 항상 null 이므로 드롭 대상을 스텁한다.
|
||||||
|
function mouse(el, type, x, y) {
|
||||||
|
el.dispatchEvent(new w.MouseEvent(type, {
|
||||||
|
bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function dragCardTo(card, target) {
|
||||||
|
d.elementFromPoint = () => target;
|
||||||
|
mouse(card, "mousedown", 10, 10);
|
||||||
|
mouse(d, "mousemove", 60, 60); // 임계값(4px) 초과 → 드래그 시작
|
||||||
|
mouse(d, "mousemove", 200, 200);
|
||||||
|
mouse(d, "mouseup", 200, 200);
|
||||||
|
d.elementFromPoint = () => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// ── ① 입력 → 계산 ──────────────────────────────────
|
||||||
|
const row1 = $(".cpg-calc-row");
|
||||||
|
row1.querySelector(".cpg-calc-name").value = "MS-1001";
|
||||||
|
row1.querySelector(".cpg-calc-qty").value = "18";
|
||||||
|
|
||||||
|
const ke = new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
|
||||||
|
row1.querySelector(".cpg-calc-qty").dispatchEvent(ke);
|
||||||
|
check("Tab 이 새 행을 만든다", $$(".cpg-calc-row").length === 2, "행수=" + $$(".cpg-calc-row").length);
|
||||||
|
check("Tab 후 포커스가 새 행 수량", d.activeElement === $$(".cpg-calc-row")[1].querySelector(".cpg-calc-qty"));
|
||||||
|
|
||||||
|
const row2 = $$(".cpg-calc-row")[1];
|
||||||
|
row2.querySelector(".cpg-calc-name").value = "MS-1004";
|
||||||
|
row2.querySelector(".cpg-calc-qty").value = "12";
|
||||||
|
|
||||||
|
$("#cpg-calc-run").click();
|
||||||
|
await new Promise(r => setTimeout(r, 20));
|
||||||
|
|
||||||
|
check("요약이 표시된다", $("#cpg-sum-body").hidden === false);
|
||||||
|
check("제품 카드 2장", $$(".cpg-sum-prod").length === 2, String($$(".cpg-sum-prod").length));
|
||||||
|
check("혼합 박스 1장", $$(".cpg-box-card").length === 1);
|
||||||
|
check("제품별 잔여 = 2박스(16개)", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("2박스16개"),
|
||||||
|
text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||||
|
check("KPI 총 박스 4", text(".cpg-kpi .cpg-kpi-value") === "4박스", text(".cpg-kpi .cpg-kpi-value"));
|
||||||
|
|
||||||
|
// ── ③ 센터는 추가한 것만 표시 ─────────────────────
|
||||||
|
check("초기 센터 패널 없음", $$(".cpg-dist-center").length === 0);
|
||||||
|
$("#cpg-center-pick").value = "1";
|
||||||
|
$("#cpg-center-add").click();
|
||||||
|
check("센터 추가 후 1개 표시", $$(".cpg-dist-center").length === 1, String($$(".cpg-dist-center").length));
|
||||||
|
check("추가한 센터 이름", text(".cpg-dist-center strong") === "대구3", text(".cpg-dist-center strong"));
|
||||||
|
|
||||||
|
// ── 마우스 드래그 → 드롭 → 대화상자 ───────────────
|
||||||
|
const card = $(".cpg-sum-prod.is-draggable");
|
||||||
|
d.elementFromPoint = () => $(".cpg-dist-center");
|
||||||
|
mouse(card, "mousedown", 10, 10);
|
||||||
|
mouse(d, "mousemove", 60, 60);
|
||||||
|
check("4px 이상 움직이면 드래그 시작", card.classList.contains("is-dragging"));
|
||||||
|
check("고스트(복제 카드) 생성", !!$(".cpg-drag-ghost"));
|
||||||
|
check("드래그 중 대상 센터 강조", $(".cpg-dist-center").classList.contains("is-over"));
|
||||||
|
mouse(d, "mouseup", 200, 200);
|
||||||
|
d.elementFromPoint = () => null;
|
||||||
|
check("드롭 후 고스트 제거", !$(".cpg-drag-ghost"));
|
||||||
|
check("드롭하면 대화상자가 열린다", $("#cpg-dlg").hidden === false);
|
||||||
|
check("대화상자에 잔여/개수 표시", text("#cpg-dlg-item").includes("잔여 2박스 (16개)"), text("#cpg-dlg-item"));
|
||||||
|
check("대화상자 센터 = 드롭한 센터", $("#cpg-dlg-center").value === "1");
|
||||||
|
|
||||||
|
// 1박스만 담기
|
||||||
|
$("#cpg-dlg-qty").value = "1";
|
||||||
|
$("#cpg-dlg-ok").click();
|
||||||
|
check("대화상자가 닫힌다", $("#cpg-dlg").hidden === true);
|
||||||
|
check("센터에 항목 1줄", $$(".cpg-dist-item").length === 1);
|
||||||
|
check("센터 항목에 박스·개수 표시", text(".cpg-dist-item .cpg-dist-unit") === "박스 · 8개",
|
||||||
|
text(".cpg-dist-item .cpg-dist-unit"));
|
||||||
|
check("센터 합계 배지 1박스/8개", text(".cpg-dist-sum").replace(/\s/g, "").startsWith("1박스8개"),
|
||||||
|
text(".cpg-dist-sum"));
|
||||||
|
check("요약 잔여가 1박스로 줄어든다", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("1박스8개"),
|
||||||
|
text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||||
|
check("KPI 센터 배분 1박스", $$(".cpg-kpi-value")[1].textContent === "1박스", $$(".cpg-kpi-value")[1].textContent);
|
||||||
|
|
||||||
|
// ── 클릭 대체 경로 + 잔여 전부 담기 ───────────────
|
||||||
|
$$(".cpg-sum-prod")[0].click();
|
||||||
|
check("클릭으로도 대화상자가 열린다", $("#cpg-dlg").hidden === false);
|
||||||
|
$("#cpg-dlg-all").click();
|
||||||
|
check("전량 담기 후 잔여 0", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("0박스0개"),
|
||||||
|
text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||||
|
check("잔여 0 카드는 드래그 불가", $$(".cpg-sum-prod")[0].classList.contains("is-done"));
|
||||||
|
check("센터 합계 2박스/16개", text(".cpg-dist-sum").replace(/\s/g, "").startsWith("2박스16개"),
|
||||||
|
text(".cpg-dist-sum"));
|
||||||
|
|
||||||
|
// ── 수량 직접 수정 (상한 = 잔여 + 자기 수량) ──────
|
||||||
|
const qty = $(".cpg-dist-item .cpg-dist-qty");
|
||||||
|
qty.value = "5"; fire(qty, "input");
|
||||||
|
check("잔여 초과 입력은 최대치로 잘림", qty.value === "2", qty.value);
|
||||||
|
|
||||||
|
qty.value = "1"; fire(qty, "input");
|
||||||
|
check("수량 줄이면 개수 표시 갱신", text(".cpg-dist-item .cpg-dist-unit") === "박스 · 8개",
|
||||||
|
text(".cpg-dist-item .cpg-dist-unit"));
|
||||||
|
check("수량 줄이면 요약 잔여 복귀", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("1박스8개"),
|
||||||
|
text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||||
|
|
||||||
|
// ── 혼합 박스 담기 ────────────────────────────────
|
||||||
|
dragCardTo($(".cpg-box-card.is-draggable"), $(".cpg-dist-center"));
|
||||||
|
check("혼합 박스도 대화상자가 열린다", $("#cpg-dlg").hidden === false);
|
||||||
|
check("혼합 박스 잔여 1박스(6개)", text("#cpg-dlg-item").includes("잔여 1박스 (6개)"), text("#cpg-dlg-item"));
|
||||||
|
$("#cpg-dlg-ok").click();
|
||||||
|
check("혼합 박스가 센터에 담긴다", $$(".cpg-dist-item").length === 2);
|
||||||
|
check("혼합 담은 뒤 배분됨 표시", $(".cpg-box-card").className.includes("is-done"));
|
||||||
|
|
||||||
|
// ── 항목 빼기 / 센터 빼기 ─────────────────────────
|
||||||
|
$$(".cpg-dist-del")[1].click();
|
||||||
|
check("항목 빼면 줄이 사라진다", $$(".cpg-dist-item").length === 1);
|
||||||
|
check("항목 빼면 혼합 박스 복귀", !$(".cpg-box-card").className.includes("is-done"));
|
||||||
|
|
||||||
|
$(".cpg-dist-close").click();
|
||||||
|
check("센터 빼기", $$(".cpg-dist-center").length === 0);
|
||||||
|
check("센터 빼면 전량 잔여 복귀", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("2박스16개"),
|
||||||
|
text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||||
|
|
||||||
|
// ── 드롭 실패 경로 방어 ───────────────────────────
|
||||||
|
$("#cpg-center-pick").value = "2";
|
||||||
|
$("#cpg-center-add").click();
|
||||||
|
check("센터 다시 추가", $$(".cpg-dist-center").length === 1, String($$(".cpg-dist-center").length));
|
||||||
|
|
||||||
|
// (1) 패널이 아닌 ③ 카드 여백에 놓아도 센터가 하나면 그 센터로
|
||||||
|
dragCardTo($(".cpg-sum-prod.is-draggable"), $(".cpg-dist-card"));
|
||||||
|
check("여백에 놓아도 대화상자가 열린다", $("#cpg-dlg").hidden === false);
|
||||||
|
$("#cpg-dlg-qty").value = "1";
|
||||||
|
$("#cpg-dlg-ok").click();
|
||||||
|
check("여백 드롭도 센터에 담긴다", $$(".cpg-dist-item").length === 1, String($$(".cpg-dist-item").length));
|
||||||
|
|
||||||
|
// (2) ③ 밖에 놓으면 담기지 않고 안내만 뜬다
|
||||||
|
$(".cpg-dist-del").click();
|
||||||
|
dragCardTo($(".cpg-sum-prod.is-draggable"), $(".cpg-calc-card"));
|
||||||
|
check("③ 밖에 놓으면 담기지 않는다", $("#cpg-dlg").hidden === true && $$(".cpg-dist-item").length === 0);
|
||||||
|
check("③ 밖 드롭 안내 메시지", text("#cpg-calc-msg").includes("센터 위에 놓아야"), text("#cpg-calc-msg"));
|
||||||
|
|
||||||
|
// (3) 드래그 없이 클릭한 경우
|
||||||
|
$(".cpg-sum-prod.is-draggable").click();
|
||||||
|
check("클릭 대체 경로 동작", $("#cpg-dlg").hidden === false);
|
||||||
|
$("#cpg-dlg-qty").value = "1";
|
||||||
|
$("#cpg-dlg-ok").click();
|
||||||
|
check("클릭으로 담긴다", $$(".cpg-dist-item").length === 1);
|
||||||
|
|
||||||
|
console.log("\n" + (fails.length ? "FAILED: " + fails.join(" | ") : "ALL PASS"));
|
||||||
|
process.exit(fails.length ? 1 : 0);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""box_calc.html 을 테스트용 HTML 로 렌더한다 (erp_base.html 은 최소 스텁으로 대체)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
OUT_DIR = os.path.join(ROOT, "tests", "js", ".out")
|
||||||
|
|
||||||
|
BASE_STUB = "{% block head_extra %}{% endblock %}<body>{% block content %}{% endblock %}</body>"
|
||||||
|
|
||||||
|
BOX_RULES = [
|
||||||
|
{"product_code": "MS-1001", "product_name_snapshot": "미라네 1호 세트",
|
||||||
|
"box_name": "쿠팡박스", "units_per_box": 8},
|
||||||
|
{"product_code": "MS-1004", "product_name_snapshot": "미라네 4호 세트",
|
||||||
|
"box_name": "쿠팡박스", "units_per_box": 8},
|
||||||
|
]
|
||||||
|
CENTERS = [{"id": 1, "name": "대구3"}, {"id": 2, "name": "인천32"}]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
env = jinja2.Environment(
|
||||||
|
loader=jinja2.ChoiceLoader([
|
||||||
|
jinja2.DictLoader({"erp_base.html": BASE_STUB}),
|
||||||
|
jinja2.FileSystemLoader(os.path.join(ROOT, "app", "modules", "cupang", "templates")),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
html = env.get_template("cupang/box_calc.html").render(box_rules=BOX_RULES, centers=CENTERS)
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
path = os.path.join(OUT_DIR, "box_calc_render.html")
|
||||||
|
io.open(path, "w", encoding="utf-8", newline="\n").write(html)
|
||||||
|
print("rendered:", path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user