feat(cafe24): 상품 목록·검색 + 현재 상세페이지 HTML 조회 (Phase 2)
상세설명 API 경로가 틀려 있던 것을 실물 확인으로 바로잡았다.
`/admin/products/{no}/description` 은 존재하지 않는다(운영몰 호출 결과
`No API found.`). 상세설명은 상품 리소스의 필드이므로 GET/PUT 을
`/admin/products/{no}` 로 옮겼고, PUT body 는 {"request": {...}} 다.
PC/모바일 상세설명이 별도 필드라는 것도 확인됐다. `separated_mobile_description`
('T'/'F') 이 분리 사용 여부이며, 미분리 상품을 수정할 때 description 만 바꾸면
모바일이 어긋난다. Descriptions 데이터클래스에 이 플래그와 불일치 여부를 담아
화면에서 경고로 노출한다.
목록 응답에는 description 이 없어(확인됨) 상세설명은 상품 1건씩 조회한다.
그래서 목록 화면에 미리보기를 뿌리지 않는다 — 상품 87개면 87호출이라 호출
제한에 걸린다.
화면은 읽기 전용이다(편집·적용은 Phase 3~4). 목록은 카페24를 매번 조회해
현재값을 보여주고, 결과를 cafe24_products 에 UPSERT 해둔다(예약·로그 화면에서
API 없이 상품명을 쓰기 위함).
상단 탭의 예약관리가 404 였으므로 Phase 5 안내 화면을 붙였다.
토큰 만료 시각이 화면에 +00:00 로 보이던 것도 고쳤다. 컬럼이 timestamptz 라
psycopg 가 UTC 로 돌려주는 값을 그대로 출력하고 있었다(시각 자체는 정확했다).
검증: 유닛테스트 23개 통과(신규 7개 — 상세설명 경로가 /description 으로
되돌아가지 않는지, PUT payload 모양, 미분리 플래그 파싱, 페이징 clamp).
라우트 8개 등록 확인. 실제 화면은 서버 배포 후 확인 필요.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -217,6 +217,52 @@ class Cafe24Store:
|
||||
).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)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 직렬화 — datetime → KST ISO, date → ISO (다른 모듈과 동일)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
|
||||
라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에
|
||||
확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다.
|
||||
routes_products 상품 목록/검색 · 상세설명 조회
|
||||
routes_system 연결(OAuth)·상태·API 로그·작업 로그
|
||||
(Phase 2~) routes_products / routes_schedules
|
||||
(Phase 5) routes_schedules
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,12 +22,14 @@ from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from .common import base_ctx, guard
|
||||
from .routes_products import products_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(system_router)
|
||||
|
||||
|
||||
@@ -36,9 +39,12 @@ def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request) -> HTMLResponse:
|
||||
"""상품 목록 (Phase 2 에서 구현). 지금은 연결 상태 안내만."""
|
||||
@router.get("/schedules", response_class=HTMLResponse)
|
||||
def schedules(request: Request) -> HTMLResponse:
|
||||
"""예약관리 안내. Phase 5 에서 routes_schedules.py 로 옮긴다.
|
||||
|
||||
상단 탭에 링크가 있으므로 404 를 내지 않고 안내 화면을 보여준다.
|
||||
"""
|
||||
from app.main import render_template # noqa: WPS433
|
||||
|
||||
checked = guard(request)
|
||||
@@ -46,11 +52,11 @@ def index(request: Request) -> HTMLResponse:
|
||||
return checked
|
||||
_st, user = checked
|
||||
|
||||
ctx = base_ctx(request, user, active_tab="products")
|
||||
ctx = base_ctx(request, user, active_tab="schedules")
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "카페24 상품관리",
|
||||
"page_subtitle": "상품 상세페이지 조회·편집·예약",
|
||||
"page_title": "카페24 — 예약관리",
|
||||
"page_subtitle": "예약 적용 · 자동 복원",
|
||||
}
|
||||
)
|
||||
return render_template(request, "cafe24/index.html", ctx)
|
||||
return render_template(request, "cafe24/schedules.html", ctx)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""카페24 상품 화면 — 목록/검색 · 현재 상세설명(HTML) 조회. Phase 2.
|
||||
|
||||
카페24를 언제나 source of truth 로 본다. 목록도 상세설명도 화면을 열 때마다
|
||||
API 로 현재값을 읽고, 목록 결과는 `cafe24_products` 캐시에 UPSERT 한다
|
||||
(예약·로그 화면에서 API 없이 상품명을 보여주기 위한 용도).
|
||||
|
||||
상세설명은 상품 리소스의 필드다(`/description` 서브리소스는 존재하지 않는다 —
|
||||
app/integrations/cafe24/products.py 주석 참고). 목록 응답에는 상세설명이 없어
|
||||
상품 1건씩 조회해야 하므로, 목록 화면에서는 미리보기를 뿌리지 않는다.
|
||||
|
||||
편집·적용은 Phase 3~4 다. 이 파일은 **읽기 전용**이며 카페24에 쓰지 않는다.
|
||||
|
||||
핸들러는 `async def` 가 아니라 `def`(동기)로 선언한다. 카페24 API·DB 호출이
|
||||
블로킹이므로 FastAPI 스레드풀에서 돌게 두는 편이 이벤트 루프를 막지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
|
||||
|
||||
from .common import base_ctx, guard
|
||||
|
||||
logger = logging.getLogger("cafe24.products")
|
||||
|
||||
products_router = APIRouter()
|
||||
|
||||
# 한 화면에 보여줄 상품 수. 카페24 1회 조회 한도(100)를 넘지 않는다.
|
||||
PAGE_SIZE = 50
|
||||
|
||||
|
||||
def _page_param(raw: str | None) -> int:
|
||||
try:
|
||||
return max(1, int(raw or 1))
|
||||
except ValueError:
|
||||
return 1
|
||||
|
||||
|
||||
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]:
|
||||
"""목록 표에 쓸 필드만 골라낸다(응답 필드가 90개라 그대로 넘기지 않는다)."""
|
||||
normalized = products.normalize_product(raw)
|
||||
return {
|
||||
**normalized,
|
||||
"updated_date": _short_dt(raw.get("updated_date")),
|
||||
"price": str(raw.get("price") or ""),
|
||||
}
|
||||
|
||||
|
||||
@products_router.get("/", response_class=HTMLResponse)
|
||||
def product_list(request: Request) -> HTMLResponse:
|
||||
"""상품 목록/검색. 검색어는 상품명 부분일치(카페24 API 가 처리)."""
|
||||
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()
|
||||
page = _page_param(request.query_params.get("page"))
|
||||
|
||||
api = build_cafe24_api(st)
|
||||
rows: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
error = ""
|
||||
try:
|
||||
total = products.count_products(api.client, product_name=keyword)
|
||||
raw_rows = products.list_products(
|
||||
api.client,
|
||||
limit=PAGE_SIZE,
|
||||
offset=(page - 1) * PAGE_SIZE,
|
||||
product_name=keyword,
|
||||
)
|
||||
rows = [_row_for_list(r) for r in raw_rows]
|
||||
st.upsert_products([products.normalize_product(r) for r in raw_rows])
|
||||
except Cafe24Error as exc:
|
||||
# 미연결/토큰만료/호출제한 모두 여기로 온다. 화면은 살려두고 사유만 알린다.
|
||||
error = str(exc)
|
||||
logger.warning("카페24 상품 목록 조회 실패: %s", exc)
|
||||
|
||||
last_page = max(1, -(-total // PAGE_SIZE)) if total else 1
|
||||
|
||||
ctx = base_ctx(request, user, active_tab="products")
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "카페24 상품관리",
|
||||
"page_subtitle": "상품 상세페이지 조회·편집·예약",
|
||||
"rows": rows,
|
||||
"keyword": keyword,
|
||||
"page": page,
|
||||
"last_page": last_page,
|
||||
"total": total,
|
||||
"error": error,
|
||||
}
|
||||
)
|
||||
return render_template(request, "cafe24/products.html", ctx)
|
||||
|
||||
|
||||
@products_router.get("/products/{product_no}", response_class=HTMLResponse)
|
||||
def product_detail(request: Request, product_no: int) -> HTMLResponse:
|
||||
"""상품 1건 — 기본정보 + 카페24에 지금 올라가 있는 상세설명 HTML."""
|
||||
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)
|
||||
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 {}
|
||||
ctx = base_ctx(request, user, active_tab="products")
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": f"카페24 상품 {product_no}",
|
||||
"page_subtitle": product.get("product_name") or "상품 상세페이지",
|
||||
"product_no": product_no,
|
||||
"info": {
|
||||
**info,
|
||||
"price": str(product.get("price") or ""),
|
||||
"updated_date": _short_dt(product.get("updated_date")),
|
||||
"summary_description": product.get("summary_description") or "",
|
||||
},
|
||||
"desc": desc,
|
||||
"error": error,
|
||||
}
|
||||
)
|
||||
return render_template(request, "cafe24/product.html", ctx)
|
||||
@@ -1,17 +0,0 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814b" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "cafe24/_nav.html" %}
|
||||
|
||||
<div class="erp-card cf24-empty">
|
||||
<h3>상품 목록은 Phase 2 에서 열립니다.</h3>
|
||||
<p>
|
||||
먼저 <a href="/cafe24/system">시스템 → 카페24 연결</a>에서 인증을 완료하세요.
|
||||
연결이 끝나면 이 화면에서 상품 조회·검색·상세페이지 편집을 할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,113 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814c" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "cafe24/_nav.html" %}
|
||||
|
||||
{% if error %}
|
||||
<div class="cf24-flash cf24-flash-err">
|
||||
카페24 조회에 실패했습니다: {{ error }}<br />
|
||||
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── 기본 정보 ─────────────────────────────────────────────── #}
|
||||
<div class="erp-card cf24-card">
|
||||
<div class="cf24-card-head">
|
||||
<h3>{{ info.product_name or '상품' }}</h3>
|
||||
<span class="cf24-muted">상품번호 {{ product_no }}</span>
|
||||
</div>
|
||||
|
||||
<table class="erp-table cf24-kv">
|
||||
<tbody>
|
||||
<tr><th>상품코드</th><td><code>{{ info.product_code or '—' }}</code></td></tr>
|
||||
<tr><th>판매가</th><td>{{ info.price or '—' }}</td></tr>
|
||||
<tr>
|
||||
<th>진열 / 판매</th>
|
||||
<td>
|
||||
{% 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 %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th>최근 수정</th><td>{{ info.updated_date or '—' }}</td></tr>
|
||||
<tr><th>요약설명</th><td>{{ info.summary_description or '—' }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="cf24-actions">
|
||||
<a class="erp-btn erp-btn-outline" href="/cafe24/">← 상품 목록</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── 상세설명 HTML (읽기 전용) ─────────────────────────────── #}
|
||||
{% if desc %}
|
||||
<div class="erp-card cf24-card">
|
||||
<div class="cf24-card-head">
|
||||
<h3>상세페이지 HTML</h3>
|
||||
<span class="cf24-muted">카페24 현재값 · 읽기 전용 (편집·적용은 Phase 3~4)</span>
|
||||
</div>
|
||||
|
||||
<table class="erp-table cf24-kv">
|
||||
<tbody>
|
||||
<tr><th>PC 상세설명</th><td>{{ desc.description | length }}자</td></tr>
|
||||
<tr>
|
||||
<th>모바일 상세설명</th>
|
||||
<td>
|
||||
{{ desc.mobile_description | length }}자 ·
|
||||
{% if desc.separated_mobile %}
|
||||
<span class="cf24-warn">PC와 분리 사용</span> — 수정 시 모바일도 따로 반영해야 합니다.
|
||||
{% else %}
|
||||
<span class="cf24-muted">PC와 동일 설정</span>
|
||||
{% endif %}
|
||||
{% if desc.mobile_differs %}<span class="cf24-warn">내용 불일치</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<label class="cf24-label" for="cf24-html-pc">PC 상세설명 HTML</label>
|
||||
<textarea id="cf24-html-pc" class="cf24-html" rows="16" readonly spellcheck="false"
|
||||
>{{ desc.description }}</textarea>
|
||||
<div class="cf24-actions">
|
||||
<button type="button" class="erp-btn erp-btn-outline" data-copy="cf24-html-pc">HTML 복사</button>
|
||||
</div>
|
||||
|
||||
{% if desc.separated_mobile or desc.mobile_differs %}
|
||||
<label class="cf24-label" for="cf24-html-mo">모바일 상세설명 HTML</label>
|
||||
<textarea id="cf24-html-mo" class="cf24-html" rows="16" readonly spellcheck="false"
|
||||
>{{ desc.mobile_description }}</textarea>
|
||||
<div class="cf24-actions">
|
||||
<button type="button" class="erp-btn erp-btn-outline" data-copy="cf24-html-mo">HTML 복사</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// HTML 복사 — clipboard API 가 막히면 textarea 선택으로 대체한다.
|
||||
document.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) { /* 사용자가 직접 복사 */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,87 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814c" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "cafe24/_nav.html" %}
|
||||
|
||||
{% if error %}
|
||||
<div class="cf24-flash cf24-flash-err">
|
||||
카페24 조회에 실패했습니다: {{ error }}<br />
|
||||
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="erp-card cf24-card">
|
||||
<div class="cf24-card-head">
|
||||
<h3>상품 목록</h3>
|
||||
<span class="cf24-muted">
|
||||
{% if keyword %}“{{ keyword }}” 검색 결과 {{ total }}건{% else %}전체 {{ total }}건{% endif %}
|
||||
· {{ page }} / {{ last_page }} 페이지
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form class="cf24-toolbar" method="get" action="/cafe24/">
|
||||
<input class="cf24-search" type="search" name="q" value="{{ keyword }}"
|
||||
placeholder="상품명으로 검색 (부분일치)" />
|
||||
<button class="erp-btn erp-btn-primary" type="submit">검색</button>
|
||||
{% if keyword %}<a class="erp-btn erp-btn-outline" href="/cafe24/">전체보기</a>{% endif %}
|
||||
</form>
|
||||
|
||||
{% if rows %}
|
||||
<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 r in rows %}
|
||||
<tr>
|
||||
<td class="cf24-nowrap">{{ r.product_no }}</td>
|
||||
<td class="cf24-nowrap"><code>{{ r.product_code }}</code></td>
|
||||
<td>{{ r.product_name }}</td>
|
||||
<td class="cf24-nowrap">
|
||||
{% if r.display %}<span class="erp-badge cf24-badge-ok">진열</span>
|
||||
{% else %}<span class="erp-badge cf24-badge-off">미진열</span>{% endif %}
|
||||
</td>
|
||||
<td class="cf24-nowrap">
|
||||
{% if r.selling %}<span class="erp-badge cf24-badge-ok">판매</span>
|
||||
{% else %}<span class="erp-badge cf24-badge-off">중지</span>{% endif %}
|
||||
</td>
|
||||
<td class="cf24-nowrap">{{ r.price }}</td>
|
||||
<td class="cf24-nowrap">{{ r.updated_date }}</td>
|
||||
<td class="cf24-nowrap">
|
||||
<a class="erp-btn erp-btn-outline" href="/cafe24/products/{{ r.product_no }}">상세페이지</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if last_page > 1 %}
|
||||
<div class="cf24-pager">
|
||||
{% if page > 1 %}
|
||||
<a class="erp-btn erp-btn-outline"
|
||||
href="/cafe24/?q={{ keyword | urlencode }}&page={{ page - 1 }}">← 이전</a>
|
||||
{% endif %}
|
||||
<span class="cf24-muted">{{ page }} / {{ last_page }}</span>
|
||||
{% if page < last_page %}
|
||||
<a class="erp-btn erp-btn-outline"
|
||||
href="/cafe24/?q={{ keyword | urlencode }}&page={{ page + 1 }}">다음 →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% elif not error %}
|
||||
<p class="cf24-muted">
|
||||
{% if keyword %}“{{ keyword }}” 로 찾은 상품이 없습니다.{% else %}표시할 상품이 없습니다.{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814c" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "cafe24/_nav.html" %}
|
||||
|
||||
<div class="erp-card cf24-empty">
|
||||
<h3>예약관리는 Phase 5 에서 열립니다.</h3>
|
||||
<p>
|
||||
지정한 시각에 상세페이지를 자동 적용하고, 종료 시각에 원래대로 되돌리는 기능입니다.
|
||||
예약은 <code>dbx-cafe24-worker</code> 컨테이너가 처리하므로 브라우저를 닫아도 실행됩니다.
|
||||
</p>
|
||||
<p>
|
||||
지금은 <a href="/cafe24/">상품 목록</a>에서 현재 상세페이지 HTML 을 확인할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814b" />
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814c" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -12,7 +12,7 @@ from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
|
||||
from app.integrations.cafe24 import config as cfgmod
|
||||
from app.integrations.cafe24 import crypto, oauth, tokens
|
||||
from app.integrations.cafe24 import crypto, oauth, products, tokens
|
||||
from app.integrations.cafe24.errors import Cafe24AuthError, Cafe24ConfigError
|
||||
from app.modules.cafe24 import store
|
||||
from app.timezone import now_kst
|
||||
@@ -240,6 +240,108 @@ def test_parse_product_no():
|
||||
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
|
||||
|
||||
|
||||
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"] == "락"
|
||||
|
||||
|
||||
def _run_all():
|
||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||
for fn in fns:
|
||||
|
||||
Reference in New Issue
Block a user