feat(cafe24): 예약관리 — 지정 시각에 상세페이지·진열/판매 자동 적용 (Phase 5)

되돌리기(자동 복원)는 요청대로 만들지 않았다. 예약은 "그 시각에 이 내용을 적용"
하나뿐이며, 한 예약에서 상세페이지 HTML·진열·판매를 각각 고를 수 있다. 셋 다
"변경 없음"인 예약은 DB CHECK 로 막는다.

등록은 편집기 아래 「예약 적용」에서 한다. HTML 을 적용하는 예약이면 그 시점의 편집기
내용을 DRAFT revision 으로 저장해 고정한다 — 이후 편집기를 더 고쳐도 예약된 내용이
바뀌지 않아야 한다. 단건 적용과 같은 다듬기(URL 인코딩 → 소스 정리)를 거치므로 화면에서
본 값이 그대로 저장된다. 예약 폼은 적용 폼과 형제로 두고(폼 중첩 불가) 편집기 내용을
JS 가 hidden 에 복사한다.

실행은 web 이 아니라 worker 다(app/modules/cafe24/worker.py, compose 서비스
dbx-cafe24-worker, --loop 60). 웹 요청 안에서 기다리면 프록시 타임아웃·재기동에
무너지고, 브라우저를 닫으면 실행되지 않는다.

worker 는 claim_due_schedule 로 한 건씩 FOR UPDATE SKIP LOCKED 로 잠그고 PROCESSING
으로 바꾼 뒤 잠금을 푼다. worker 가 둘 떠도 같은 예약을 두 번 적용하지 않고, 긴 API
호출 동안 DB 잠금을 쥐지 않는다. 적용 순서는 화면 편집과 같다(현재값 재조회 → BACKUP
→ PUT → 감사로그). HTML 없이 진열/판매만 바꾸는 예약은 상세설명을 읽지도 백업하지도
않는다. 실패는 1분→5분→15분 재시도 후 FAILED 확정이며, 한 건의 오류로 worker 가
죽지 않는다.

진열/판매를 한 번의 PUT 으로 함께 보내려고 products.update_product 를 추가했다
(update_descriptions 는 이 함수로 위임). None 인 필드는 payload 에서 빼므로 "건드리지
않음"이 그대로 표현된다.

DB: scripts/sql/cafe24_db_002_schedule_flags.sql (멱등) — set_display/set_selling
BOOLEAN NULL 추가 + 아무것도 하지 않는 예약 금지 제약. 되돌리기용 end_* 컬럼은 쓰지
않지만 삭제하지 않는다(파괴적).

시각은 KST 로 해석한다(datetime-local 은 타임존이 없다). 과거는 거부하되 폼을 채우는
동안 시간이 흐른 경우를 위해 1분 여유를 뒀다.

검증: 유닛테스트 66개 통과(신규 15개 — 3-상태 파싱, KST 해석·과거 거부·1분 여유,
요약 문구, payload 의 T/F 와 None 생략, 바꿀 것 없으면 미호출, worker 의 성공 경로
(백업+PC/모바일 동시+진열만 전송)·상태만 변경 시 백업 생략·재시도 후 최종 실패·
버전 누락 시 크래시 대신 실패·처리할 것 없을 때 종료). 예약 목록/편집기 예약 폼 렌더 확인.
라우트 16개. 실제 예약 실행은 서버 배포 후 확인 필요.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 16:06:48 +09:00
parent b2a70ad0ab
commit 6dae0e45c9
16 changed files with 1157 additions and 69 deletions
@@ -63,6 +63,60 @@
</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" />
<div class="cf24-schedule-grid">
<label>
<span>예약 시각</span>
<input class="cf24-schedule-input" type="datetime-local" name="scheduled_at" required />
</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 %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814n" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260814p" />
{% endblock %}
{% block content %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814n" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260814p" />
{% endblock %}
{% block content %}
@@ -234,6 +234,20 @@
});
}
// 예약 폼 — 편집기 내용을 hidden 에 복사해 함께 보낸다(폼이 서로 형제라서).
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 : "";
if (!window.confirm(schedForm.dataset.confirm)) { e.preventDefault(); return; }
// 예약 등록으로 화면을 떠나므로 편집 중 경고를 끈다.
dirty = false;
});
}
var form = pane.querySelector(".cf24-editor-form");
if (form) {
form.addEventListener("submit", function (e) {
@@ -1,20 +1,81 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814n" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260814p" />
{% 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 을 확인할 수 있습니다.
{% 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;"
onsubmit="return 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 %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814n" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260814p" />
{% endblock %}
{% block content %}