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:
2026-08-14 12:05:19 +09:00
parent 4be7c7f580
commit 07626bcaf8
13 changed files with 742 additions and 76 deletions
@@ -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 %}