52 Commits

Author SHA1 Message Date
king 47e09b94d7 feat(cupang): 드래그 조작감 개선 — 핸들·플레이스홀더·오토스크롤
Pointer Events 기반으로 바꾸고(터치·펜 지원, setPointerCapture 로 창 밖
이동에도 이벤트 유실 없음) 업무툴 수준의 조작 피드백을 붙였다.

- 드래그 핸들(⠿)에서만 드래그 시작 → 카드 클릭/텍스트 선택과 충돌 없음
- 커서를 따라오는 고스트에 "잔여 N박스" 배지, 담을 수 없는 위치에서는
  빨간 테두리로 금지 표시
- 대상 센터에 "○○ — 여기에 담기" 플레이스홀더를 끼워 놓일 자리를 보여줌
- 센터 목록 위/아래 44px 안에 커서가 오면 자동 스크롤(센터가 많을 때 필수)
- pointermove 처리는 requestAnimationFrame 으로 1프레임 1회만 실행
- pointercancel / 창 blur / Esc 에서 드래그를 안전하게 취소

SortableJS 도입은 보류했다. 웨일에서 네이티브 DnD 가 시작조차 되지 않아
어차피 fallback 모드로만 동작하고, 브라우저 실측 검증 수단이 없는 상태에서
의존성만 늘어난다.

tests/js: 핸들 밖 드래그 차단·고스트 배지·플레이스홀더·금지 표시·Esc 취소
시나리오 추가(rAF 지연을 고려해 프레임 대기). 전체 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 03:28:12 +09:00
king e7db2a90fc fix(cupang): 센터 분배를 마우스 기반 드래그로 교체 (웨일 대응)
웨일에서 카드를 눌러 움직여도 드래그 미리보기조차 생기지 않았다 =
HTML5 네이티브 DnD 가 시작 자체를 못 한 것. 브라우저 구현에 기대는
draggable/dragstart 를 걷어내고 마우스 이벤트로 직접 구현했다.

- mousedown 후 4px 이상 이동하면 드래그 시작, 카드 복제본(고스트)이
  커서를 따라온다. 커서 아래 센터 패널은 elementFromPoint 로 찾아 강조.
- mouseup 위치의 센터에 놓으면 수량 대화상자가 열린다. ③ 카드 여백에
  놓아도 센터가 하나면 그 센터로, ③ 밖이면 안내만 띄우고 담지 않는다.
- Esc 로 드래그 취소. 카드 위에서 손을 뗀 경우에만 뒤따르는 click 을
  1회 무시해, 드래그 후에도 클릭 경로가 막히지 않는다.

tests/js: 드래그 시작 임계값·고스트 생성/제거·대상 강조·여백 드롭·
③ 밖 드롭·클릭 대체 경로까지 마우스 이벤트로 검증. 전체 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 03:17:08 +09:00
king ae7daabae5 fix(cupang): 센터 분배 드래그 실패 지점 제거·센터 목록 가나다 정렬
드래그가 브라우저에서 먹지 않던 문제를 추정 대신 실패 가능 지점별로
전부 막았다.

- dragstart 리스너를 카드 요소에 직접 바인딩(위임으로는 잡히지 않는
  환경 대비). 렌더할 때마다 다시 바인딩한다.
- dragenter/dragover 를 모두 preventDefault — 하나만 막으면 드롭을
  거부하는 브라우저가 있다.
- 드롭 대상 범위를 ③ 카드 전체로 확대하고, 대상 패널은
  closest → elementFromPoint → (센터가 하나면) 그 센터 순으로 찾는다.
  패널 사이 여백에 놓아도 담긴다.
- dataTransfer.setData/getData 가 막힌 환경 대비로 dragKey 를 따로 보관해
  드롭 시 사용한다. 그래도 못 읽으면 클릭으로 담으라는 안내를 띄운다.
- 드래그 중에는 ③ 영역에 점선 강조를 준다.
- 드래그 직후 발생하는 click 은 무시해 대화상자가 두 번 뜨지 않게 한다.

센터 선택 목록은 이름 가나다순 정렬(한글 음절은 코드포인트 순).

tests/js: 여백 드롭·dragenter·setData 차단 환경 시나리오 추가. 전체 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 03:08:09 +09:00
king ead1361790 fix(cupang): 센터 분배 드롭 동작·수량 대화상자·센터 선택
드래그가 먹지 않던 원인: 드롭 대상이 점선 상자(.cpg-dist-drop) 안쪽으로
한정돼 센터 카드 여백에 놓으면 dragover 에서 preventDefault 가 걸리지
않았다 → 드롭 대상을 센터 패널 전체로 넓혔다. 카드를 클릭해도 같은
대화상자가 열리는 대체 경로도 추가.

- ③ 은 처음에 비어 있고, 셀렉트에서 고른 센터만 패널로 추가된다(✕ 로 제거,
  제거하면 담긴 박스는 잔여로 복귀)
- 드롭/클릭 시 대화상자에서 박스 수량을 입력한다. "잔여 전부 담기" 버튼으로
  남은 수량을 한 번에 담을 수 있고, Enter 로 확정 / Esc 로 취소
- 센터에 담긴 줄과 센터 합계에 박스 수와 상품 수(박스×입수량)를 함께 표시
- 요약 카드에도 잔여 개수(잔여 박스 × 입수량)를 표시

tests/js: jsdom 으로 계산→드래그→대화상자→수량 수정→제거까지 36개 항목을
검증하는 UI 테스트 추가(README 에 실행법). 전체 통과 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 03:01:01 +09:00
king 2de019a203 feat(cupang): 박스 계산 3열 작업공간 + 센터 분배 드래그
① 박스 계산: 제품명·수량만 입력(결과 열 제거). 수량 칸에서 Tab 은 다음
   행 수량으로 이동하고 마지막 행이면 새 행을 추가한다. Enter 는 계산.
② 박스 요약: 제품별 박스/자투리 혼합 박스 카드가 드래그 소스. 표시되는
   수치는 센터에 배분하고 남은 "잔여" 기준으로 실시간 갱신되며, KPI 는
   총 박스 / 센터 배분 / 미배분 잔여 3개로 바꿨다.
③ 센터 분배: 활성 입고센터(cupang_centers)만 표시. 카드를 끌어다 놓으면
   해당 센터에 1박스가 담기고, 박스 수량을 직접 입력할 수 있다(잔여를
   넘으면 자동으로 잘림). ✕ 로 빼면 잔여로 되돌아간다.

혼합 박스는 내용이 서로 달라 1장 = 1박스로 취급한다. 분배 내역은 화면
안에서만 유지(DB 저장 없음)하며, 재계산하면 근거가 바뀌므로 초기화한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 02:47:48 +09:00
king fe344f9fda fix(cupang): 박스 계산 표 표기 정리·상자 라벨 고정
- 제품 드롭다운은 제품명만 표시(박스·입수량 병기 제거)
- 표 머리글 가운데 정렬 + 굵게
- 셀 옆에 나타나던 "…"" 제거: input/button 이 든 셀까지 text-overflow:
  ellipsis 가 걸려 빈 말줄임표가 그려졌다. 말줄임은 글자 셀에만 적용
- 혼합 박스 그림의 호수 추출 로직 삭제 → 항상 "쿠팡박스" 표기(글자 수에
  맞춰 라벨 크기 11px)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 02:27:01 +09:00
king 354eebeaac chore(cupang): 박스 입수량 박스명을 '쿠팡박스' 로 통일
운영 DB 일괄 변경 스크립트(cupang_box_rules_unify_box_name.sql) 추가.
기존 호수(2호/3호/6호)는 사라지지 않게 memo 뒤로 옮긴다
(예: "피킹비 750원 · 2호").

시드 SQL 2개도 같은 규칙으로 맞춰, 재실행해도 호수 박스명이
되살아나지 않게 했다.

주의: 박스명은 박스 계산의 "자투리 혼합" 그룹 기준이라, 통일 후에는
2호/3호/6호 자투리가 한 박스에 섞여 계산된다(사용자 확인됨).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 02:22:52 +09:00
king 07f71d1df0 feat(cupang): 혼합 박스 그림에 박스 호수 표시
상자 SVG 안에 "2호"처럼 호수만 넣는다("쿠팡 " 접두어 제거).
채움 색 위에서도 읽히도록 흰색 외곽선(paint-order: stroke)을 준다.
aria-label 도 "2호 박스"로 바꿔 스크린리더에서 구분 가능하게 함.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:41:33 +09:00
king c36ba70a16 feat(cupang): 박스 입수량 2차 시드 SQL (미라클통/백/빠져락/멀티/점보)
19건 추가. 제품코드는 cupang_products 에서 제품명(공백·대소문자 무시)
으로 매칭하고, 카탈로그에 없는 제품은 입력하지 않고 목록으로 출력한다
— 제품명 설정의 수기 추가로 코드를 등록한 뒤 재실행하면 반영된다.
피킹비는 memo 에 기록.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:26:18 +09:00
king 5c54c99f7e feat(cupang): 제품명 설정에 수기 추가 폼
itemcode_db 목록에 없는 상품도 제품명·제품코드를 직접 입력해 등록할 수
있게 왼쪽 카드 상단에 폼 추가. 기존 POST /cupang/products (upsert)를
그대로 사용하며 같은 제품코드는 덮어쓴다. itemcode 검색이 비활성인
환경에서도 동작하도록 search_enabled 분기 밖에 둔다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:50:22 +09:00
king 414c47e24d fix(cupang): 계산표 제품명 열만 좁히고 나머지 열 헤더 잘림 복구
제품명 열이 남는 폭을 전부 가져가면서 다른 열이 눌려 헤더가 "박…"
처럼 잘렸다. 제품명 열에 고정 폭 180px 을 주고 수량 78 / 박스·입수량
116 / 숫자 64 / 삭제 40 으로 되돌려, 남는 폭은 지정 폭 비율대로
나눠 갖게 한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:40:14 +09:00
king a23cbd7b67 fix(cupang): 계산표 가로 스크롤 제거·좌우 카드 상단선 정렬
요약 카드에 남아 있던 margin-top:16px 이 2열 그리드에서 오른쪽 카드를
아래로 밀고 있었다 → 0 으로.

가로 스크롤: fixed 레이아웃에서도 셀 내용이 밖으로 삐져나오면 래퍼가
스크롤되므로 th/td 에 overflow:hidden + text-overflow:ellipsis 를 주고
래퍼는 overflow-x:hidden 으로 고정. 열 폭도 축소(수량 68 / 박스 96 /
숫자 46 / 삭제 34). 드롭다운 라벨은 "미라네 1호 세트 (2호·8)" 처럼
"쿠팡 " 접두어를 떼어 짧게 표기.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:38:41 +09:00
king aab5157386 fix(cupang): 박스 계산표 가로 스크롤 제거·좌우 카드 높이 정렬
.erp-input 의 min-width:240px 때문에 수량 칸이 넓어져 표가 가로로
넘쳤다. 계산표를 table-layout:fixed + colgroup 고정폭으로 바꾸고
수량/선택 입력의 min-width 를 해제, 셀 여백을 8px 6px 로 축소.

박스명·입수량 열을 "박스 / 입수량" 한 열로 합치고(쿠팡 2호 · 8개),
헤더를 박스/낱개/필요 로 줄이고 삭제 버튼을 ✕ 아이콘으로 바꿔 열 수를
8 → 7 로 줄였다.

2열 그리드를 align-items:stretch 로 두고 카드를 flex column 으로 만들어
좌우 카드 높이를 맞춘다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:35:48 +09:00
king 127f396366 fix(cupang): 박스 계산 화면 2열 배치·표 잘림 방지
입력표와 요약을 좌우 2열 그리드로 배치(1280px 이하는 세로 쌓기).
입력표는 자체 스크롤(max-height + sticky thead)로 행이 늘어도 카드가
잘리지 않게 하고, 요약 그리드 최소 폭을 줄여 좁은 열에서도 카드가
한 줄에 맞게 조정.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:31:33 +09:00
king c6ee37ee48 feat(cupang): 박스 계산 요약 — 자투리 혼합 박스 시각화
하단 "박스명별 합계" 표를 요약 카드로 교체.
- KPI 3개: 총 박스 / 제품별 박스 / 혼합 박스(자투리 N개)
- 제품별 카드: 몇 박스 + 자투리 몇 개(딱 맞으면 "딱 맞음")
- 자투리 혼합 박스: 박스 1개당 카드로 어떤 상품이 몇 개 들어가는지 표시.
  상자 SVG(뚜껑 열림·내용물 차오름), 채움률 바, 카드 등장 애니메이션.
  prefers-reduced-motion 에서는 애니메이션 비활성.

혼합 계산은 서버 _pack_leftovers: 박스 용량을 1 로 두고 제품 1개 =
1/units_per_box 부피로 환산, 같은 박스명끼리만 채운다. 부동소수 오차를
피하려고 Fraction 사용. 한 제품 자투리가 두 박스로 나뉘는 것은 허용해
박스 수가 최소가 되게 한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:28:34 +09:00
king c72c0710ad feat(cupang): 박스 계산 화면 추가
달력 상단 "박스 계산" 버튼 → /cupang/box-calc. 제품명+수량을 여러 행
입력하면 제품별 박스 수·남은 낱개·총 필요 박스와 박스명별 합계를 계산.
수량/제품을 고치고 "계산 / 재계산" 으로 다시 계산할 수 있다(수량 칸에서
Enter 도 동일). 저장하지 않는 계산 전용 화면.

계산은 POST /cupang/api/box-calc 에서 store.compute_boxes 로 수행 —
클라이언트 계산을 신뢰하지 않는다. 입수량 규칙이 없는 제품은 "미설정"
으로 표시하고 합계에서 제외.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:18:52 +09:00
king 7eb63ff664 feat(cupang): 등록 제품명 표 정렬 + 박스 입수량 시드 SQL
제품명/제품코드 헤더 클릭으로 오름·내림차순 토글. Intl.Collator(ko,
numeric) 사용해 "미라네 2호"가 "미라네 10호"보다 앞에 오도록 정렬.
검색 비활성 상태에서도 동작하게 스크립트를 search_enabled 밖에 배치.

박스 입수량 14건(미라네 1~7호, 황토 김치통 1~7호) 시드 SQL 추가.
피킹비 전용 컬럼이 없어 memo 에 기록.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:13:22 +09:00
king 55d1c1d6f3 fix(cafe24): 단축키 안내에 줄 복사 추가·편집창 툴팁 제거
- 안내 문구를 "주석토글[Ctrl+/] · 줄 복사[Alt+Shift+↑↓] · 줄 이동[Alt+↑↓]
  · 줄 삭제[Shift+Del]" 로 맞춘다(줄 복사가 빠져 있었다).
- textarea 의 title 툴팁을 뺀다. 편집 중 마우스 옆에 떠서 소스를 가렸다.
  같은 내용이 위 안내줄에 항상 보이므로 잃는 정보가 없다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:01:44 +09:00
king 739bb80b04 fix(cafe24): 편집기 상단 단축키 안내·가격 표기·선택행 hover
- 편집기 바 왼쪽 문구를 단축키 안내만 남긴다. 글자 수·PC/모바일 공통은
  화면에서 쓸 일이 없어 뺐다. 옅은 파랑(#79a6dd)으로 본문과 구분한다.
- 가격을 '6900.00' 대신 '6,900원' 으로 보여준다. 원 단위 쇼핑몰이라 소수점
  아래는 언제나 .00 이므로 버린다. 숫자로 못 읽으면 받은 값을 그대로 둔다.
- 선택된 행에 마우스를 올려도 배경이 변하지 않게 한다. erp-shell.css 의
  `.erp-table tbody tr:hover` 가 특이도가 더 높아 검은 배경을 덮어쓰고
  있었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:50:57 +09:00
king 8cfe963526 feat(erp): 공용 확인/경고 창으로 브라우저 기본 confirm 대체
브라우저 기본 confirm()/alert() 은 제목에 도메인이 찍히고("dbx.no1king.
freeddns.org 내용:") 위치·정렬·아이콘을 바꿀 수 없다. 같은 역할의 창을 직접
그려 화면 정중앙에 띄운다.

- app/static/erp-dialog.{js,css} 신규. 제목은 "No.1 King ERP 프로그램",
  내용은 가운데 정렬, 왼쪽에 경고 삼각형 아이콘(인라인 SVG - 외부 아이콘
  라이브러리를 쓰지 않는다).
- erpConfirm()/erpAlert() 은 Promise 를 돌려준다. 기본 confirm() 과 달리
  동기 반환이 불가능하므로 호출부를 then() 으로 바꿨다. 폼 제출은 일단
  막고 확인 후 form.submit() 으로 다시 보낸다(required 검사는 그 전에 이미
  통과한다).
- HTML 만으로 쓰려면 form 에 data-erp-confirm 을 달면 된다. 카페24 시스템·
  예약 화면의 인라인 onsubmit=confirm(...) 을 이 방식으로 옮겼다.
- 메시지는 textContent 로 넣어 서버·사용자 문자열이 HTML 로 해석되지 않는다.
- Esc 취소 / Enter 확인 / Tab 은 창 안에서만 돌고, 닫을 때 원래 포커스로
  돌아간다. 바깥을 누르면 취소(안전한 쪽).
- erp_base.html 에서 전역 로드. defer 를 쓰지 않아 각 화면의 인라인
  스크립트가 곧바로 쓸 수 있다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:33:42 +09:00
king a58fa7b83b feat(cafe24): HTML 편집기 Shift+Delete 줄 삭제
커서가 있는 줄(선택이 있으면 그 줄들)을 통째로 지운다. 윈도우 기본 동작인
"잘라내기"를 대신한다.

- 줄바꿈까지 함께 지워 빈 줄이 남지 않게 한다. 마지막 줄이면 대신 앞의
  줄바꿈을 지운다(문서 끝에 빈 줄이 생기지 않게).
- 커서는 지운 자리로 올라온 줄의 같은 칸에 둔다(칸이 모자라면 줄 끝).
- 빈 문자열은 insertText 가 브라우저에 따라 무시되므로 applyEdit 이
  execCommand("delete") 로 갈라진다. Ctrl+Z 는 그대로 동작한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:22:17 +09:00
king 024653dc8e feat(cafe24): HTML 편집기 줄 이동·복사 단축키
Alt+↑/↓ 로 커서가 있는 줄(선택이 있으면 그 줄들)을 위/아래로 옮기고,
Alt+Shift+↑/↓ 로 같은 줄을 하나 더 만든다. 상세페이지는 <img> 한 줄이 곧
한 구획이라 순서 바꾸기·반복이 잦다.

- 이동은 윗줄/아랫줄과 통째로 자리를 바꾸는 방식이라 줄 수가 변하지 않는다.
  첫 줄 위·마지막 줄 아래에서는 아무 것도 하지 않는다.
- 복사는 위로 하면 커서가 원래 자리(위쪽 사본)에, 아래로 하면 새로 생긴
  아래쪽 사본으로 간다.
- 주석 토글과 같은 줄 범위 규칙을 쓰도록 lineRange()/applyEdit() 로 묶었다.
  선택이 개행에서 끝나면 다음 줄은 대상이 아니다.
- execCommand("insertText") 라 Ctrl+Z 로 되돌아간다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:17:18 +09:00
king 233c8db631 feat(cafe24): HTML 편집기 Ctrl+/ 주석 토글
- 선택이 있으면 선택이 걸친 줄 전체가 대상. 반쯤 걸친 줄이 잘려 태그가
  깨지지 않게 하기 위함. 선택이 없으면 커서가 있는 줄 하나.
- 대상 안에 주석 기호가 하나라도 있으면 제거, 없으면 블록 전체를 <!-- -->
  로 감싼다. HTML 주석은 중첩이 안 되므로 이미 주석인 부분을 또 감싸지
  않는다.
- 선택이 개행에서 끝나면 다음 줄은 제외(줄 끝까지 드래그했을 때 아래 줄이
  딸려오지 않게).
- execCommand("insertText") 로 넣어 Ctrl+Z 이력에 남긴다. 미지원 브라우저는
  value 를 직접 바꾼다.
- 되돌린 뒤 커서/선택 위치를 복원한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:10:50 +09:00
king 3537e058b0 feat(cafe24): 편집기에서 상품명 수정
제목 옆 연필 버튼 → 입력칸 → 저장. 이름만 바꾸려고 카페24 관리자에 들어갈
필요가 없어진다.

- POST /cafe24/products/{no}/name (JSON) 추가. 쓰기 전에 카페24 현재값을 읽어
  이전 이름을 감사로그(rename_product)에 남긴다 - 되돌릴 revision 이 없으므로
  로그가 유일한 복구 단서다. 값이 같으면 호출하지 않는다.
- build_update_payload/update_product 에 product_name 추가(부분 수정이라
  상세설명·진열·판매는 그대로).
- 빈 값과 250자 초과는 서버에서 400. 화면도 maxlength 로 막는다.
- 평소에는 읽기 전용 제목이고 연필을 눌러야 입력칸이 된다 - 클릭 한 번으로
  실수로 고쳐지지 않게. Enter 저장, Esc 취소.
- 화면은 요청값이 아니라 카페24가 확인해 준 이름으로 다시 그리고, 왼쪽 목록의
  이름·정렬키(data-name)도 함께 갱신한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:35:37 +09:00
king 5127600a3d feat(cafe24): 진열/판매 배지 클릭으로 상태 토글
편집기 오른쪽 위 배지를 눌러 진열·판매를 바로 바꾼다. 상태만 바꾸려고
카페24 관리자에 들어갈 필요가 없어진다.

- POST /cafe24/products/{no}/status (JSON) 추가. 상세설명은 건드리지 않아
  BACKUP revision 을 만들지 않는다 - 되돌릴 HTML 이 없고 다시 눌러 복구된다.
- 요청값을 낙관적으로 반영하지 않고 쓰기 후 카페24가 돌려준 실제 상태로
  화면을 다시 그린다. 실패해도 화면과 카페24가 어긋나지 않는다.
- 왼쪽 목록의 점과 정렬용 data-display/data-selling 도 함께 갱신(목록을
  다시 받지 않으므로).
- 카페24 조회 실패 시에는 현재 상태를 믿을 수 없어 배지를 버튼으로 만들지
  않는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:38:33 +09:00
king 67c28ae1c7 fix(cafe24): 예약 날짜/시간 — 칸 전체 클릭 반응 + 요일 표시 + 날짜 폭 확대
1) 칸 전체를 클릭해야 팝업이 뜨지 않던 문제

네이티브 date/time input 은 자신의 달력 아이콘(우측 끝 작은 영역)을 클릭해야만
팝업이 뜬다. 투명한 네이티브 input 을 칸 전체 크기로 늘려놔도 그 작은 아이콘
영역만 반응하는 건 그대로였다 — 실제 "오른쪽 부분을 선택해야 나온다"로 보고된
문제 그대로다.

클릭 핸들러에서 showPicker() 를 직접 호출해 칸 어디를 클릭해도 팝업이 뜨게
했다. 미지원 브라우저에서는 조용히 무시되고 기존처럼 포커스만 이동한다.

실제 클릭(진짜 사용자 제스처)으로 검증했다 — showPicker() 는 클릭당 한 번만
유효한 제스처를 소비하므로, 같은 클릭에서 두 번째 호출을 시도하면
"NotAllowedError: requires a user gesture" 가 나는 것으로 첫 호출이 성공했음을
간접 확인했다. 이 미리보기 도구는 네이티브 OS 팝업을 스크린샷에 담지 못해
시각적으로는 확인할 수 없었다(도구 제약이지 코드 문제 아님).

2) 요일 표시

"2026년 08월 20일" 뒤에 "(목)" 을 붙인다. new Date("YYYY-MM-DD") 로 문자열을
그대로 파싱하면 UTC 로 해석돼 하루 밀릴 수 있어, 연/월/일을 분해해
new Date(y, mo-1, d) 로 로컬 시간대 기준으로 만든 뒤 getDay() 를 쓴다.

3) 날짜 칸 폭 확대

요일이 붙어 텍스트가 길어진 만큼 날짜 칸을 시간 칸보다 넓게 잡았다
(flex: 1.7 대 1). 라벨 전체 폭도 240px→300px 로 늘렸다.

검증: 유닛테스트 61개 통과(백엔드 무변경). 브라우저 실측 — "2026년 08월 20일 (목)"
표시 확인, 날짜/시간 칸 모두 잘림 없음(날짜 185px·시간 109px), showPicker 지원
확인, 가로 스크롤 없음.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:47:03 +09:00
king 86914f0bc6 feat(cafe24): 예약 시각을 한글 형식("2026년 08월 20일" / "오후 07시 30분")으로 표시
datetime-local 을 date+time 으로 나눈 뒤에도 여전히 브라우저가 강제하는 표시
형식("2026. 08. 20.", "오후 07:30")이 남아 있었다. 네이티브 date/time input 은
표시 형식을 CSS 로 바꿀 수 없다(브라우저·로캘가 강제) — 지원되는 방법이 없다.

코드 편집기의 오버레이 방식(.cf24-code-input 투명 textarea 를 .cf24-code-hl 색칠된
글자 위에 겹치는 것)을 그대로 적용했다. 네이티브 date/time input 을 opacity:0 으로
완전히 투명하게 두고, 우리가 형식화한 텍스트(.cf24-dt-display) 바로 위에 포개 놓는다.
클릭·키보드·달력 팝업은 네이티브가 그대로 처리하고(elementFromPoint 로 클릭이
네이티브에 도달하는지 확인함), 화면에 보이는 글자만 JS(paintDate/paintTime)가
input/change 마다 다시 그린다. input.value 자체는 형식과 무관하게 항상
YYYY-MM-DD / HH:MM 이라 제출 시 합치는 로직(store.parse_schedule_at 이 받는 형식)은
전혀 바뀌지 않았다.

정오/자정 12시간제 변환(0시→오전 12시, 12시→오후 12시)을 포함해 처리한다.

폭도 줄여달라는 요청에 맞춰, 실측으로 잘리지 않는 최소값(글자크기 12px·필드폭
117px)을 찾아 적용했다 — 이전 174px 대비 크게 줄었다.

검증(브라우저 실측): 표시 텍스트가 정확한 한글 형식으로 나옴, 정오/자정 처리 정확,
잘림 없음, 클릭이 실제 네이티브 input 에 도달, 제출 시 hidden 필드가
"2026-08-20T19:30" 으로 정확히 조합됨, 가로 스크롤 없음. 백엔드 무변경이라
유닛테스트 61개 그대로 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:37:52 +09:00
king 0862c5572b fix(cafe24): 예약 시각을 datetime-local 대신 날짜+시간 별도 입력으로
datetime-local 단일 입력은 한국어 로캘에서 "2026. 08. 14. 오후 07:00" 형태로
렌더링되는데 브라우저·로캘마다 표시 폭이 달라 잘리는 사고가 있었다(실제 발생,
직전 커밋에서 칸 폭을 넓혀 임시로 막았지만 근본 원인은 남아 있었다).

type=date + type=time 두 개의 별도 input 으로 바꿨다. 각각 폭이 고정이라 잘릴
여지가 없다. 제출 직전 JS 가 두 값을 "YYYY-MM-DD" + "T" + "HH:MM" 로 합쳐 hidden
scheduled_at 에 넣는다 — 이 형식은 예전 datetime-local 이 만들던 값과 동일해서
서버(store.parse_schedule_at)는 손대지 않았다. date/time 두 입력에 required 를
둬서, 브라우저의 기본 폼 검증이 빈 값 제출을 막는다(hidden 필드는 타입 특성상
required 검증 대상이 아니라 시각 입력 쪽에 걸었다).

검증: 유닛테스트 61개 통과(백엔드 무변경이라 영향 없음). 브라우저 실측 — 두 입력
각 170px 로 내부 잘림 없음(scrollWidth==clientWidth), 가로 스크롤 없음, 실제 제출
이벤트를 발생시켜 2026-08-20 19:30 입력이 hidden 필드에 "2026-08-20T19:30" 으로
정확히 합쳐지는 것을 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:24:42 +09:00
king 8bdf8695f9 fix(cafe24): 예약 시각 입력란이 잘려서 시간이 안 보이던 문제
datetime-local 입력란이 한국어 로캘로 "2026. 08. 14. 오후 07:00" 형태로 렌더링되는데,
예약 폼 grid 가 auto-fit minmax(160px, 1fr) 라 칸이 170px 밖에 안 돼 시간 부분이
잘렸다.

예약 시각 칸에만 전용 클래스(cf24-schedule-datetime)를 붙여 grid-column: span 2 로
두 칸을 차지하게 했다. 진열·판매 select 는 원래 폭으로도 충분해 그대로 뒀다.

검증(브라우저 실측): 예약 시각 입력란 폭 348px, scrollWidth==clientWidth(내부 잘림
없음), 진열/판매가 나란히 이어지고 메모가 다음 줄 전체폭을 차지, 가로 스크롤 없음.
유닛테스트 61개 통과(레이아웃 변경만이라 로직 테스트는 영향 없음).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:15:16 +09:00
king 8d93d09dac fix(cafe24): 예약·버전이력을 펼쳐도 편집기 크기 유지 — 오른쪽 칸만 스크롤
「예약 적용」이나 「버전 이력」을 펼치면 코드 칸이 눌려 작아졌다. .cf24-code 와
.cf24-editor-form 이 flex: 1 1 auto 라 아래 내용이 늘어나면 그만큼 편집기가 줄어드는
구조였다 — 펼칠 때마다 보고 있던 위치를 잃는다.

둘을 flex: 0 0 auto 로 바꾸고 코드 칸에 height: 56vh 를 줬다. 무엇을 펼쳐도 높이가
그대로이고, 늘어난 내용은 아래로 밀리며 오른쪽 칸(.cf24-pane-editor)이 스크롤된다.
vh 를 쓴 것은 창 크기에 적응하기 위함이다(고정 px 는 작은 화면에서 넘친다).

56vh 는 실측으로 고른 값이다. 62vh 는 접힌 상태에서도 51px 넘쳐 요약줄이 잘렸고,
56vh 는 넘침 0 이라 두 요약줄이 스크롤 없이 보인다. calc(100% - 260px) 은 342px 까지
줄어 편집 영역이 너무 좁았다.

검증(브라우저 실측): 접힘/펼침 모두 코드 칸 506px 동일, 접힘 시 넘침 0, 펼치면 오른쪽
칸이 498px 스크롤, 오른쪽 칸을 200px 스크롤해도 색칠 층·줄 번호·textarea 정렬 유지,
페이지 가로 스크롤 없음, 편집 영역 25줄. 유닛테스트 61개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:10:17 +09:00
king 469de15998 feat(cafe24): 편집기에 상품 다이렉트 주소 + 클립보드 복사
상세 화면 상단에 고객이 보는 상세페이지 주소를 보여주고 「주소 복사」·「쇼핑몰에서
열기」를 붙였다.

    https://miras.co.kr/product/detail.html?product_no=119

도메인은 하드코딩하지 않고 CAFE24_SHOP_URL 로 받는다. 커스텀 도메인은 mall_id 로
알 수 없기 때문이다. 미설정 시 카페24 기본 도메인(https://<mall_id>.cafe24.com)으로
대체해 환경변수가 없어도 항상 유효한 주소가 나온다. 스킴 누락·끝 슬래시도 정규화한다.

주소 칸은 readonly <input> 이라 기존 복사 버튼(data-copy)이 값을 그대로 읽어간다 —
클립보드 로직을 새로 만들지 않았다. 클립보드 API 가 막힌 환경에서는 입력칸 선택으로
대체되고, 칸을 클릭하면 전체 선택된다.

CAFE24_SHOP_URL 은 .env.example 과 문서에 설명을 함께 넣었다(신규 환경변수 규칙).

검증: 유닛테스트 61개 통과(신규 2개 — 스킴/슬래시 유무 3가지 입력에서 같은 주소,
미설정 시 카페24 도메인 대체). 렌더 확인 — 주소 표시·복사 버튼·새 창 열기(noopener),
주소를 만들 수 없으면 줄 자체를 숨김.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:46:04 +09:00
king 8ec38db6d1 revert(cafe24): 일괄수정 기능 제거
사용자 결정에 따라 화면·라우트·로직·문서를 모두 제거했다.

제거 대상:
  routes_bulk.py            (파일 삭제)
  templates/cafe24/bulk.html (파일 삭제)
  _nav.html                 「일괄수정」 탭
  router.py                 import·include·설명
  store.py                  find_style_blocks / replace_first_style_block / 정규식
  tests                     <style> 블록 교체 테스트 6건
  docs                      2-3 절, 경로 표 3줄, 구조도, Phase 7 표기

DB 는 손대지 않았다. 일괄수정은 전용 스키마를 만들지 않았고, 기존 테이블에 남은
기록은 **실제로 상품에 적용된 변경의 이력**이다. 특히 그때 만들어진 BACKUP revision
은 일괄 적용 이전 내용을 되찾을 유일한 수단이라 지우면 복구가 불가능해진다.
감사로그(action='bulk_style')도 누가 언제 무엇을 바꿨는지 남기는 기록이라 보존한다.
정말 지워야 한다면 별도로 요청받아 진행한다.

부수 수정: 예약 시각 검증 테스트가 실행 시각의 초에 따라 실패할 수 있었다.
`datetime-local` 은 초를 버리므로 지금 이 분(分)을 고르면 최대 59초 과거가 되는데,
테스트가 now 의 초를 고정하지 않아 경계에서 흔들렸다. now 를 고정해 결정적으로 만들었다
(3회 반복 실행으로 확인).

검증: 유닛테스트 59개 통과, 라우트 13개, 템플릿 컴파일 확인, 모듈에 bulk 참조 0건.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:14:40 +09:00
king 6dae0e45c9 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>
2026-08-14 16:06:48 +09:00
king b2a70ad0ab fix(cafe24): 블록 선택 시 글자가 안 보이던 문제 — 선택 배경 반투명
편집기는 투명한 textarea 를 색칠된 <pre> 위에 겹쳐 놓은 구조다. textarea 의 글자는
투명이고 실제 글자는 아래 <pre> 가 그린다. 선택 배경은 위층인 textarea 가 칠하므로
불투명(#b3d4fc)이면 아래 글자를 완전히 덮는다 — 드래그로 블록을 잡으면 파란 칸만
남고 글자가 사라졌다.

::selection 배경을 rgba(51,122,226,0.28) 로 바꿔 색칠된 글자가 비쳐 보이게 했다.
Firefox 용 ::-moz-selection 도 함께 넣었다.

검증: 브라우저에서 규칙 적용 확인(rgba 0.28), 선택 상태에서도 색칠 층이 글자를 그대로
그리고 있음 확인. 유닛테스트 51개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:53:01 +09:00
king 8e3e2d1f76 style(cafe24): 편집기 상단 설명 문단 제거
편집기 위 안내 문단(PC/모바일 공통·소스 정리·한글 경로 설명)을 삭제했다. 매번 읽을
내용이 아니고 편집 영역을 밀어내고 있었다. 동작 자체를 설명하는 내용은
docs/CAFE24_MODULE.md 2-2 절에 그대로 남아 있다.

.cf24-note 스타일은 일괄수정 화면에서 계속 쓰므로 유지한다 — 거기서는 지워질 CSS 를
경고하는 내용이라 읽어야 한다.

검증: 유닛테스트 51개 통과. 렌더 확인 — 설명 문단만 사라지고 라벨·메모·복사·
다시 읽기·적용·버전 이력·코드 편집기는 그대로.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:49:54 +09:00
king 39dc28d664 feat(cafe24): 편집기에서 PC/모바일 구분 제거 — 항상 같은 내용으로 반영
요청대로 단순화했다. 모바일 상세설명 보기 영역과 「모바일도 함께」 체크박스를 없애고,
적용 시 description 과 mobile_description 에 **항상 같은 HTML** 을 쓴다.
separated_mobile_description 값과 무관하므로 한쪽만 바뀌어 어긋나는 사고가 없어진다.

다만 분리 사용 상품의 모바일 내용이 PC 와 달랐다면 그 내용을 덮어쓰게 된다. 그래서
쓰기 전에 **모바일 내용도 BACKUP revision 으로 따로 남긴다** — 백업이 없으면 되찾을
방법이 없다. 기존 BACKUP 은 PC 값만 담고 있었다.

부수 정리: 편집기 컨텍스트에서 html_mobile 제거, mobile_html 의 None 분기 제거,
감사로그 표기를 "PC·모바일 동시 반영"으로 고정, 라벨을 "상세설명 HTML · PC/모바일
공통"으로 변경.

검증: 유닛테스트 51개 통과. 분리+내용상이 상품으로 편집기를 렌더해 모바일 보기 영역·
체크박스가 사라지고 공통 문구·버전 이력·다시 읽기가 남은 것을 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:46:32 +09:00
king 34476ba611 feat(cafe24): 진열중·판매중 기본 체크 + 분리 상품 모바일 동시 반영 선택
1) 필터 기본값

진열중·판매중을 기본 체크로 바꿨다. 체크박스는 해제 상태면 아무 값도 보내지 않아
기본값이 체크면 "사용자가 일부러 해제함"을 구분할 수 없다. 그래서 폼에 표식(f=1)을
넣어, 표식이 없으면 첫 방문(기본값), 있으면 실제 체크 상태를 따르게 했다. 표식은
목록 링크·적용 후 리다이렉트에도 이어 붙어 해제 상태가 유지된다.

2) 분리 상품의 모바일 반영

"소스를 수정하면 PC와 모바일이 같이 수정되는 것 아닌가" 라는 지적대로, PC/모바일
분리 사용 상품은 지금까지 PC 만 바뀌고 있었다(미분리 상품은 원래 함께 반영).

편집기에 「모바일도 함께」 체크박스를 추가했다. 현재 두 내용이 같으면 기본 체크라
그대로 적용하면 함께 반영되고, 내용이 다르면 기본 해제하고 경고를 띄운다 — 일부러
다르게 만든 모바일 페이지를 조용히 덮어쓰는 것이 더 큰 사고이기 때문이다. 미분리
상품은 종전처럼 항상 함께 반영하며 체크박스를 보여주지 않는다.

검증: 유닛테스트 51개 통과. 필터 판정을 5가지 경우로 확인(첫 방문·둘 다 체크·하나만·
둘 다 해제·검색 링크) — 둘 다 해제가 f=1 표식으로 유지됨. 편집기 렌더를 3가지
상태로 확인(분리+동일=기본체크, 분리+상이=기본해제+경고, 미분리=체크박스 없음).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:41:07 +09:00
king 9b9bafdf95 fix(cafe24): 카페24에서 바뀐 소스가 화면에 반영되지 않던 문제 — 캐시 금지 + 다시 읽기
카페24 관리자에서 소스를 고쳤는데 우리 화면은 예전 것을 보여주는 문제.

편집기 조각(GET /products/{no}/pane)과 목록 화면 응답에 캐시 헤더가 없었다.
브라우저가 이전 응답을 재사용하면 카페24의 현재값이 아닌 예전 소스가 그려진다.
그 상태에서 편집·적용하면 카페24 관리자에서 한 수정을 덮어쓰게 되므로, 단순한
표시 문제가 아니라 데이터 손실로 이어질 수 있다.

응답에 Cache-Control: no-store 를 붙이고 조각을 가져가는 fetch 에도
cache: "no-store" 를 걸었다. 일괄수정 검사 조회도 같다.

편집기에 [다시 읽기] 버튼을 추가했다. 카페24 관리자에서 방금 고친 경우 목록을
다시 그리지 않고 그 상품의 현재 소스만 강제로 받아온다. 편집 중이면 저장 안 됨
경고를 먼저 띄운다.

카페24 API 가 쓰기 직후 잠시 예전 값을 돌려줄 가능성도 있다(읽기 지연). 그 경우도
[다시 읽기] 로 확인할 수 있게 했다.

검증: 유닛테스트 51개 통과. 렌더된 편집기 JS 를 브라우저에서 구문 검사(new Function)
통과, no-store·다시 읽기 반영 확인. 라우트 13개.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:46:55 +09:00
king 3522fc2119 feat(cafe24): 일괄수정 — 상세페이지 <style> 블록 통일
87개 상품의 상세설명 맨 위 <style> 을 정해진 내용으로 바꾸는 화면을 추가했다.

    <style>
    	div {
    		text-align: center;
    	}
    </style>

그냥 덮어쓰지 않고 검사 → 선택 → 적용 2단계로 만들었다. 상품 131번의 style 안에는
"비디오 태그 모바일 반응형 스타일" 같은 CSS 가 들어 있어서, 무엇이 지워지는지 보지
않고 87건을 일괄 실행하면 필요한 규칙이 조용히 사라진다. 검사 결과 표에 지금 들어
있는 CSS 를 그대로 보여주고, 변경이 필요한 상품만 자동 선택한다(이미 같은 내용이면
「이미 동일」로 제외).

맨 앞 <style> 블록 하나만 바꾼다. 아래쪽에 <style> 이 더 있으면 건드리지 않고
「블록 2개 · 주의」로 표시해 사람이 판단하게 한다 — 일괄 작업이 남의 CSS 를 조용히
지우는 것이 가장 위험하다. 블록이 없는 상품은 맨 앞에 넣는다.

상품 1건당 1요청으로 쪼갰다. 87건을 한 요청으로 묶으면 1분 가까이 걸려 프록시
타임아웃에 걸리고, 동시에 던지면 카페24 호출 제한(429)에 걸린다. 브라우저가 순차
호출하며 진행률을 보여주고, 한 건 실패가 나머지를 막지 않으며 어디까지 됐는지
화면에 남는다.

적용 순서는 단건 편집과 같은 원칙을 지킨다: 카페24 현재값 재조회 → BACKUP 버전 →
교체 → PUT → MANUAL 버전 + 감사로그(action=bulk_style). 검사 때 읽은 값을 재사용하지
않고 쓰기 직전에 다시 읽는다. PC/모바일 분리 상품은 모바일도 함께 바꾼다.

검증: 유닛테스트 51개 통과(신규 6개 — 앞 블록만 교체하고 뒤 블록 보존, 없을 때 삽입,
멱등, 포맷 후 탭 유지, 여러 줄 원문 정확히 절단). 실제 데이터로 미리보기 로직 확인:
비디오 CSS 가 "지워질 내용"에 잡히고, 이미 동일한 상품은 will_change=False,
style 없는 상품은 삽입 대상으로 판정. 라우트 13개 등록, 템플릿 렌더 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:12:24 +09:00
king 25ed369583 revert(cafe24): 상단 공통 홍보 숨기기 기능 제거
사용자 결정에 따라 없던 기능으로 되돌린다. 카페24 API 로 스킨 detail.html 의
상품번호 목록을 고칠 수 없다는 것이 확인된 뒤, 대안(상세설명 CSS 주입 / 스크립트
태그)을 모두 채택하지 않기로 정리했다.

제거 대상:
  store.py        HIDE_PROMO_* 상수, has_hidden_promo, set_promo_hidden
  routes_products 편집기 컨텍스트의 promo 상태, hide_promo 폼 필드,
                  적용 시 블록 주입, 분리 상품 모바일 블록 동기화, 감사로그 표기
  _editor.html    「상단 공통 홍보 숨기기」 체크박스
  cafe24.css      .cf24-check-inline
  tests           promo 관련 4건
  docs            2-3 절

적용 시 "변경 없음" 판정에 모바일 비교를 포함시킨 것은 남겼다. 공통 홍보와 무관하게
맞는 동작이다(PC 는 그대로여도 모바일이 PC 와 다르면 맞춰줘야 한다).

문서 정리도 함께 했다. 앞선 편집에서 3-1·2-2 절이 중복 삽입되고 절 순서가
뒤섞여 있던 것을 2 → 2-1 → 2-2 → 3 → 3-1 → 3-2 로 바로잡았다. 편집기 절에는
아직 이전 방식(크기 맞추기·sticky 줄번호)이 적혀 있었는데 실제 구현인 스크롤
동기화로 갱신하고, 크기 계산이 두 번 실패한 이유를 근거 수치와 함께 남겼다.

⚠️ 이미 이 기능으로 적용한 상품이 있다면 그 상품 상세설명에
   <style id="cf24-hide-common-promo"> 블록이 남아 있다. 편집기에서 그 줄을 지우고
   적용하면 된다.

검증: 유닛테스트 45개 통과, 라우트 10개 등록, 템플릿 컴파일 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:03:10 +09:00
king e96c0636f5 revert(cafe24): 디자인 scope 요청 철회 — 스킨 파일 API 가 존재하지 않음
운영몰에서 직접 확인한 결과(API 버전 2026-03-01):
  GET /admin/themes/pages → No API found. (엔드포인트 없음)
  GET /admin/themes       → 디자인 권한이 있으면 조회 가능하나 테마 목록뿐,
                            스킨 파일 내용은 응답에 없음
스킨 HTML 파일(product/detail.html)을 읽거나 쓰는 엔드포인트는 아예 없다.

권한이 아니라 기능이 없는 문제이므로 디자인 scope 를 요청해도 쓸 데가 없다.
DEFAULT_SCOPES 를 상품 권한만으로 되돌린다 — 쓰지 않는 권한을 토큰에 담아두면
유출 시 피해 범위만 넓어진다. 아직 재인증하지 않은 상태라(insufficient_scope 로
확인됨) 되돌리는 데 추가 조치가 필요하지 않다.

같은 시도를 반복하지 않도록 확인 사실을 config.py 와 문서에 표로 남겼다.
스크립트 태그(/admin/scripttags)가 유일한 주입 수단이며 mall.write_store 권한과
외부 공개 HTTPS JS 엔드포인트가 필요하다는 점도 함께 적었다.

상단 공통 홍보 숨김은 상품 상세설명 CSS 방식(set_promo_hidden)을 그대로 쓴다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:57:40 +09:00
king a1e222acb9 test(cafe24): scope 검증을 DEFAULT_SCOPES 기준으로
디자인 scope 를 추가하면서 요청 scope 문자열을 하드코딩한 테스트가 깨졌다.
DEFAULT_SCOPES 를 기준으로 비교하도록 바꿔, 앞으로 scope 가 늘어도 authorize 와
클라이언트가 같은 목록을 쓰는지만 확인하게 했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:54:55 +09:00
king 87fca22ea0 feat(cafe24): 디자인 스코프 추가 (mall.read_design/write_design)
상단 공통 홍보를 스킨 쪽에서 처리할 수 있는지 실물로 확인하려면 토큰에 디자인
권한이 있어야 한다. 개발자센터 앱에는 이미 추가됐고, 우리가 요청하는 scope 에도
넣는다. 재인증을 해야 실제 토큰에 반영된다.

확인해 둔 사실을 config.py 주석으로 남겼다. 카페24 Admin API 에는 스킨 HTML
파일(detail.html)을 직접 읽거나 쓰는 엔드포인트가 없다. 조회 가능한 것은 테마
목록과 테마 페이지뿐이며, 스크립트 태그 주입은 디자인이 아니라 mall.write_store
권한이고 인라인 코드가 아닌 외부 HTTPS URL 만 받는다.

요청 scope 를 DEFAULT_SCOPES 한 곳으로 모아, 앞으로 늘어날 때 authorize 와
클라이언트가 어긋나지 않게 했다.

검증: 유닛테스트 49개 통과. scope_param 출력 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:54:15 +09:00
king 08ebe7b53d feat(cafe24): 상단 공통 홍보 숨기기 체크박스 (PC·모바일 동시)
요청은 스킨 detail.html 의 `const numbers = [...]` 를 고치는 것이었지만, 카페24
Admin API 로는 스킨 HTML 파일을 읽거나 쓸 수 없다. 확인 결과 테마는 조회만
가능하고(GET /admin/themes) 스킨 파일 엔드포인트가 없다. 쓸 수 있는 것은 테마
페이지와 스크립트 태그뿐이다.

그래서 같은 결과를 상품 상세설명 안의 CSS 로 낸다. 상세설명은 이미 우리가 쓸 수
있는 영역이고, 상품별로 켜고 끌 수 있으며, 상태가 그 상품 소스에 그대로 보인다.
새 권한이나 재인증도 필요하지 않다.

    <style id="cf24-hide-common-promo">.edb-img-tag-w{display:none !important}</style>

id 로 우리 블록만 찾으므로 사람이 쓴 <style> 은 건드리지 않는다. 넣기/빼기는
멱등이고 소스 정리(format_html)를 거쳐도 상태가 유지된다.

PC·모바일 모두 반영한다. 미분리 상품은 같은 HTML 이 양쪽에 들어가고, 분리 상품은
모바일 본문을 건드리지 않되 이 블록만 모바일에도 맞춘다 — 양쪽에 걸지 않으면 한쪽에
홍보가 그대로 남는다. PC/모바일 상태가 다르면 화면에 불일치를 알린다.

"변경 없음" 판정에 모바일 변경도 포함시켰다. PC 는 그대로인데 모바일 숨김만
바뀌는 경우가 있어서, 예전 조건이면 아무 일도 하지 않고 끝났다.

스킨의 numbers 목록과는 독립이며 충돌하지 않는다(스킨은 요소 제거, 이쪽은 CSS 숨김).
이미 목록에 있는 상품은 그대로 두면 된다.

검증: 유닛테스트 49개 통과(신규 4개 — 추가/제거 왕복, 멱등, 포맷 통과 후 인식,
사람이 쓴 style 보존).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:44:43 +09:00
king b60690d3e1 fix(cafe24): 편집기 커서 위치가 어긋나던 문제 — 스크롤 동기화로 전환
증상: 줄 끝에 커서를 두고 엔터를 치면 커서보다 앞쪽에서 줄이 나뉘었다.

원인은 두 층의 크기를 맞추는 방식이었다. 색칠된 <pre> 가 상자 크기를 정하고
textarea 가 그 위를 덮게 했는데, flex 자식에서 `width: max-content` 가 기대대로
적용되지 않아 화면보다 긴 줄이 있으면 textarea 만 내부 스크롤이 가능한 상태가 됐다
(실측 scrollWidth 706 vs clientWidth 686). 캐럿을 따라 textarea 가 내부적으로
스크롤되면 색칠 층은 제자리에 남아, 보이는 글자와 실제 문자 오프셋이 어긋난다.
그래서 커서를 둔 곳과 다른 위치에 개행이 들어갔다.

크기를 맞추려는 시도를 버리고 스크롤 주체를 textarea 로 두고 색칠 층과 줄 번호를
transform 으로 같은 양만큼 이동시킨다. 크기 계산이 아예 없으므로 어긋날 여지가
없다. 줄 번호 칸도 코드 영역 왼쪽의 독립 박스로 바꿔(sticky 제거) 가로 스크롤과
무관해졌다.

검증(브라우저 실측): 가로 스크롤 33px·세로 200px 에서 색칠 층과 줄 번호가 정확히
같은 양만큼 이동, 스크롤 0 에서 두 층의 텍스트 원점 일치, 줄 번호 개수가 줄 수와
일치(10/10, 70/70). 유닛테스트 45개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:41:09 +09:00
king 5284fb1c6e fix(cafe24): 소스 들여쓰기 정상화 + 줄 번호·가로 스크롤 편집기
1) 들여쓰기가 안 되던 원인

인라인 요소가 여러 줄에 걸쳐 있으면 그 구간을 하나의 텍스트 덩어리로 다뤄
첫 줄에만 들여쓰기를 붙이고 있었다. 그래서 <img> 가 한 줄에 하나씩 적힌
상세페이지에서 두 번째 <img> 부터 1열에 붙어 나왔다.

원문 줄바꿈을 살리면서 각 줄을 현재 깊이로 들여쓰도록 고쳤다. 줄 앞 공백은
렌더링에 영향이 없으므로 안전하다. 상세페이지는 <img> 를 한 줄에 하나씩 적어두는
경우가 많고 그 모양이 저자의 의도라, 한 줄로 합치는 것보다 살리는 쪽이 읽기 좋다.

주석도 줄을 강제로 나누지 않게 바꿨다. `<!-- 대파_타임랩스 --><img ...>` 처럼 바로
뒤 요소를 설명하는 주석이 많아서, 나누면 라벨과 대상이 떨어져 오히려 읽기 나빠진다.

빈 줄은 구획 표시로 한 줄까지 유지한다. 이 과정에서 멱등성이 다시 깨지는 것을
테스트가 잡았다 — 텍스트 끝에 남은 "\n  " 조각이 매번 빈 줄로 바뀌고 있었고,
양 끝 공백을 함께 제거하도록 고쳤다.

2) 줄 번호와 가로 스크롤

전문 편집기처럼 줄 번호 칸을 넣었다. 줄바꿈을 허용하면 한 논리 줄이 여러 행이 되어
번호가 어긋나므로, 줄바꿈을 끄고(white-space:pre + wrap=off) 가로로 스크롤한다.
번호 칸은 position:sticky 라 가로로 스크롤해도 왼쪽에 남는다.

크기 계산도 단순해졌다. <pre> 가 흐름에 남아 상자 크기를 정하고 textarea 가
inset:0 으로 그 위를 덮는다 — JS 로 높이를 맞추지 않으니 어긋날 여지가 없다.
색상 계열은 그대로 뒀다(태그 초록·속성 갈색·값 남색·주석 회색).

검증: 유닛테스트 45개 통과(신규 4개 — 모든 줄 들여쓰기, 주석과 요소 붙임,
빈 줄 1개 유지, 실제 상세페이지 모양 멱등). 브라우저 실측: 줄 번호 개수가 줄 수와
일치(18/18, 19/19), 번호와 코드의 세로 위치 일치, 긴 줄에서 편집기 안에서만 가로
스크롤(1920 > 716)되고 페이지는 가로 스크롤 없음, 400px 스크롤 후에도 번호 칸 고정,
textarea 내부 스크롤 0(두 층 정렬 유지).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:03:54 +09:00
king 1d3dac3bec feat(cafe24): 문법 강조 편집기 + 소스 자동 정리, 목록 550px
1) 검색 입력란이 거대했던 버그

.cf24-filters 가 세로 flex 인데 .cf24-search 에 flex:0 1 320px 을 줬다. 세로
방향에서는 flex-basis 가 '높이'로 적용돼 입력란이 320px 짜리 상자가 됐다.
height:32px 로 한 줄에 고정했고, 그만큼 목록이 더 보인다.

2) 목록 550px

요청대로 왼쪽을 550px 로 넓혔다. 남은 폭(약 290px)이 상품명 몫이라 대부분 한 줄에
들어가고, 수정일도 월-일 시:분까지 보여준다. 컬럼은 번호·상품명·진열·판매·수정 5개
그대로다.

3) 문법 강조 편집기

색칠된 <pre> 위에 투명한 <textarea> 를 겹치는 방식으로 직접 구현했다. 외부
라이브러리를 쓰지 않는 이유는 자체 호스팅 원칙이다(CDN 의존 금지). 태그·속성이름·
속성값·주석·기호를 색으로 구분하고 Tab 은 들여쓰기로 쓴다.

두 층의 글자가 어긋나지 않으려면 폰트·줄높이·padding·줄바꿈 규칙이 완전히 같아야
한다. 특히 높이는 <pre> 의 scrollHeight 를 기준으로 textarea 에 지정한다 —
textarea 의 scrollHeight 를 쓰면 두 줄쯤 더 잡혀 어긋난다(실측 830 vs 792,
브라우저에서 확인 후 수정). 20만 자를 넘으면 강조를 끈다.

4) 소스 정리(포맷)와 저장 반영

store.format_html 을 추가했다. 화면 표시와 저장에 같은 함수를 쓰므로 화면에서 본
정리된 소스가 그대로 카페24에 저장된다.

렌더링을 바꾸지 않는 것을 최우선으로 했다. HTML 에서 공백은 의미가 있어서 인라인
요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다 — 이미지 사이가 벌어지는 고전적인
사고다. 그래서 블록 요소 경계에서만 줄을 나누고 img·br·span·a 는 블록 목록에서
일부러 뺐다. <style>·<script>·<pre>·<textarea> 안쪽은 한 글자도 건드리지 않는다.
내용이 한 줄뿐인 짧은 블록은 다시 한 줄로 합친다.

멱등성을 테스트로 고정했다. 처음 구현은 <style> 안 빈 줄이 실행마다 한 줄씩 늘어나
멱등이 깨졌고(테스트가 잡음), 앞뒤 빈 줄을 버리도록 고쳤다. 편집하지 않고 다시
적용해도 저장값이 계속 달라지면 버전 이력이 의미를 잃는다.

닫는 태그가 빠진 HTML 이 흔하므로 들여쓰기 상한(12)을 뒀고, 어떤 이유로든 실패하면
원본을 그대로 돌려준다.

검증: 유닛테스트 41개 통과(신규 8개 — 블록 분리·인라인 보존(이미지 붙음)·style
원문 보존·멱등·짧은 블록 합치기·깨진 HTML 내성·속성값 미변경·정리+인코딩 왕복).
브라우저 실측: 검색란 32px, 목록 550px/편집기 750px, 오버레이 두 층 높이 일치
(편집 전 792=792, 20줄 추가 후 1175=1175), 토큰 색상 적용, 가로 스크롤 없음.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:48:23 +09:00
king 119a128ee0 feat(cafe24): 상품관리를 좌우 2분할로 — 목록(좁게) | 상세페이지 편집(넓게)
왼쪽에서 상품을 클릭하면 오른쪽에 편집기가 바로 열린다. 목록을 다시 받지 않고
오른쪽 조각만 교체한다(GET /products/{no}/pane → JS 삽입). 목록까지 다시 그리면
클릭마다 카페24 호출이 2회 더 늘어나기 때문이다. JS 가 실패하거나 없으면 각 행의
링크(/cafe24/?selected=)로 그대로 동작한다.

목록은 페이지를 없애고 전체를 한 번에 받는다(list_all_products, 1회 100개·상한
1000개). 필터·정렬을 한 페이지에만 적용하면 다음 페이지에 있는 상품이 빠져
"진열중만 보기" 가 거짓이 된다. 현재 87개라 1회 호출로 끝난다.

컬럼은 요청대로 번호·상품명·진열·판매·수정 5개다. 좁은 칸에 맞춰 진열/판매는
배지 대신 점, 수정일은 월-일만 표시하고 전체 값은 title 로 둔다. 긴 상품명은
2줄로 제한해 행 높이를 고르게 유지한다(전체 이름은 title·편집기 제목에서 확인).

진열중/판매중 체크박스는 중복 선택이 되며 둘 다 켜면 AND 다. 문서에 없는 API
필터 파라미터에 기대지 않고 받아온 뒤 파이썬에서 걸러낸다. 제목행 클릭은
오름↔내림 토글이며 한글 정렬은 localeCompare(ko) 를 쓴다.

편집 영역을 넓게 쓰려고 이 화면에서만 .erp-page 의 max-width 를 풀었다. 이때
box-sizing:border-box 를 함께 줘야 한다 — width:100% + padding:24px 이라
max-width 만 풀면 문서 전체에 가로 스크롤이 생긴다(측정으로 확인 후 수정).

편집 중 다른 상품을 클릭하거나 페이지를 벗어나면 저장 안 됨 경고를 띄운다.

옛 단독 화면(product.html)은 제거하고 /products/{no} 는 2분할 화면으로
리다이렉트한다. 편집기 조각을 두 곳에서 함께 쓰도록 _editor.html 로 분리했다.

검증: 유닛테스트 33개 통과(신규 3개 — 전체 조회의 페이지 순회·상한 처리·1회
종료). 상한 처리는 테스트가 잡아서 고쳤다(요청한 만큼 받았는지로 판정). 가짜
데이터로 렌더해 브라우저에서 실측: 왼쪽 360px·오른쪽 940px, 각 칸 독립 스크롤,
분할 영역이 화면 높이에 맞고, 가로 스크롤 없음, 정렬 오름/내림 동작 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:34:52 +09:00
king 7a2e933c16 feat(cafe24): 상세페이지 HTML 편집·적용 + 이미지 경로 한글 표시
1) 한글 파일명이 %EC%9A%A9… 으로 보이던 문제

카페24는 상세페이지 안 이미지 경로를 퍼센트 인코딩해서 저장한다. 화면에서는
읽을 수 없으므로 store.decode_html_urls 로 풀어 보여주고, 저장할 때
encode_html_urls 로 되돌린다. 두 함수는 서로의 역이며 왕복이 보존된다 —
편집하지 않고 적용해도 카페24 저장값이 한 바이트도 달라지지 않는다.

깨뜨리지 않기 위한 두 가지 제약을 뒀다. 디코딩은 non-ASCII(%80~%FF)만 한다.
%20·%3C 를 풀면 URL·HTML 구조가 깨진다. 인코딩은 src/href/poster/data-src 와
CSS url() 안의 값만 한다. 본문 한글 텍스트를 인코딩하면 페이지가 망가진다.
UTF-8 로 해석되지 않는 이스케이프(EUC-KR 등)는 건드리지 않고 그대로 둔다.

2) 편집 후 적용

POST /cafe24/products/{no}/apply 는 이 순서를 지킨다.
  카페24 현재값 재조회 → BACKUP revision → 지문 대조 → PUT → MANUAL revision
현재값을 다시 읽는 것은 로컬 DB 의 마지막 버전이 지금 카페24에 올라간 값이라고
믿을 수 없기 때문이다(관리자 페이지에서 직접 고쳤을 수 있다). 지문(sha256 앞
32자)은 편집 중 남이 바꾼 내용을 조용히 덮어쓰는 것을 막는 낙관적 잠금이다.

미분리 상품(separated_mobile_description='F')은 모바일 필드도 같은 HTML 로
함께 쓴다. PC 만 바꾸면 모바일 상세가 어긋난다. 분리 상품은 모바일을 건드리지
않고 화면에 별도 반영 안내를 띄운다.

빈 내용은 거부한다(상세페이지를 통째로 날리는 실수 방지). 변경이 없으면 API 를
호출하지 않는다. 실패 시에도 BACKUP 은 남아 있으므로 오류 메시지에 버전 번호를
알려준다. 편집 중 페이지 이탈 경고도 넣었다.

버전 이력 표를 상세 화면에 붙였다(목록 조회는 html_content 를 제외하고 길이만
계산한다 — 수 MB 가 될 수 있다). 버전 선택 복원은 Phase 6.

검증: 유닛테스트 30개 통과(신규 7개 — 실제 파일명으로 왕복 동일성, ASCII
이스케이프 미변환, 본문 한글 보존, CSS url(), 잘못된 UTF-8 무시, 지문).
실제 쓰기(PUT)는 서버 배포 후 테스트 상품 1건으로 확인 필요.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:14:42 +09:00
king 07626bcaf8 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>
2026-08-14 12:05:19 +09:00
king 4be7c7f580 fix(cafe24): 페이지 내용이 잘려 [카페24 연결] 버튼이 안 보이던 문제
.erp-page 는 flex column + overflow-y:auto 인데 자식의 flex-shrink 기본값이
1 이라, 내용이 화면 높이보다 길어지면 카드가 눌려 아래쪽이 잘렸다. 넘친 게
없다고 판단되어 스크롤바도 생기지 않아 시스템 화면의 연결 버튼에 접근할 수
없었다.

카페24 템플릿에서만 로드되는 cafe24.css 에 `.erp-page > * { flex-shrink: 0 }`
를 넣어 축소를 막았다. 전역 erp-shell.css 를 고치지 않은 것은 다른 모듈
(휴가·쿠팡 등)이 .vac/.cpg 로 "한 화면 채움" 레이아웃을 쓰고 있어 회귀 위험이
있기 때문이다.

캐시된 CSS 가 쓰이지 않게 두 템플릿의 버전 쿼리를 20260814a→b 로 올렸다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:04:08 +09:00
king c6fb8ed375 feat(cafe24): 상품 상세페이지 관리 모듈 Phase 1
카페24 관리자에 직접 접속하지 않고 상품 상세페이지(description HTML)를
편집·예약 적용·복원하기 위한 모듈의 기반을 만든다. Phase 1 은 공통
Integration 계층, cafe24_db, OAuth 연결 화면까지다.

카페24 OAuth/API 클라이언트를 상품관리 모듈 안에 두지 않고
app/integrations/cafe24/ 로 분리했다. 향후 추가할 주문관리(주문 조회·송장
일괄등록·취소/반품/교환)가 같은 토큰과 클라이언트를 그대로 재사용해야 하기
때문이다. 라우터에서 httpx 를 직접 부르지 않고 Cafe24Client 만 쓰게 해서
재시도·rate limit·API 로그·토큰 갱신을 한 곳에 모았다.

토큰은 Fernet 으로 암호화해 저장한다(CAFE24_TOKEN_SECRET). DB 덤프가
유출돼도 access/refresh token 이 평문으로 남지 않게 하기 위함이며, API 로그와
연결 상태 화면에는 토큰·시크릿을 일절 기록/표시하지 않는다.

토큰 갱신은 행 잠금(SELECT ... FOR UPDATE) 안에서 한다. 카페24는 refresh
token 을 회전시키므로, 이후 추가될 예약 worker 컨테이너와 web 컨테이너가
동시에 갱신하면 한쪽 토큰이 무효화된다.

기존 파일 변경은 목록에 한 줄씩 추가하는 형태로 44줄뿐이며 기존 라우트·
테이블·인증 로직은 건드리지 않았다. CAFE24_DB_URL 미설정 시 store 가 None
이라 앱은 정상 기동하고 모듈만 "설정 필요" 안내를 표시한다.

가드 헬퍼를 common.py 로 분리한 것은 router.py 가 routes_system.py 를
include 하는 구조에서 순환 import 가 생기기 때문이다.

검증: 신규 테스트 16개 통과(암호화 왕복, 토큰 만료·자동갱신, 상태 노출 시
토큰 미유출, 재시도 예산, 예약 상태 전이). dispatch 기존 테스트 9개 통과.
cafe24_db_init.sql 은 로컬에 Docker 가 없어 미실행 — 서버 적용 시 확인 필요.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 00:23:02 +09:00
56 changed files with 9092 additions and 10 deletions
+28
View File
@@ -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 에서 검색해 등록한다(읽기만).
# 미설정 시 검색 비활성 → 수동 등록만 가능. # 미설정 시 검색 비활성 → 수동 등록만 가능.
+1
View File
@@ -23,3 +23,4 @@ __pycache__/
# dispatch 라벨 PDF 샘플(실제 고객 PII — 커밋 금지) # dispatch 라벨 PDF 샘플(실제 고객 PII — 커밋 금지)
docs/samples/ docs/samples/
tests/js/.out/
+1
View File
@@ -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`.
+5
View File
@@ -0,0 +1,5 @@
"""외부 서비스 연동 공통 계층.
모듈(app/modules/*)에 종속되지 않는 재사용 가능한 API 클라이언트를 둔다.
현재: cafe24 (상품관리 + 향후 주문관리가 공유).
"""
+95
View File
@@ -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)
+258
View File
@@ -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)
+117
View File
@@ -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"),
)
+51
View File
@@ -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
+45
View File
@@ -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
+145
View File
@@ -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},
)
+254
View File
@@ -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")),
}
+185
View File
@@ -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
View File
@@ -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"])
+48
View File
@@ -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)
+118
View File
@@ -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,
}
+482
View File
@@ -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"]
+39
View File
@@ -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"}
+488
View File
@@ -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,
)
+189
View File
@@ -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"],
}
+162
View File
@@ -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)
+441
View File
@@ -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 쇼핑몰에 바로 반영됩니다. 적용할까요?&#10;&#10;직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.">
<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 === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;";
});
}
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 토큰을 삭제합니다. 계속할까요?&#10;(변경 이력·예약 데이터는 지워지지 않습니다)">
<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 %}
+790
View File
@@ -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()
+198
View File
@@ -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())
+160
View File
@@ -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,760 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829f" />{% 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 raf = window.requestAnimationFrame ? window.requestAnimationFrame.bind(window) : null;
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 handleHtml() {
return '<span class="cpg-drag-handle" title="끌어서 센터로 담기" aria-label="드래그 핸들"></span>';
}
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">' +
(remain ? handleHtml() : "") +
'<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">' + (remain ? handleHtml() : "") +
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();
});
// ── 드래그 엔진 (Pointer Events) ─────────────────
// HTML5 네이티브 DnD 는 브라우저(웨일 등)에서 시작조차 안 되는 경우가 있어 쓰지 않는다.
// · 핸들(⠿)에서만 드래그 시작 → 카드 클릭/텍스트 선택과 충돌 없음
// · setPointerCapture 로 창 밖·다른 요소 위에서도 이벤트를 놓치지 않음
// · 커서 위치의 센터에 "여기에 담기" 플레이스홀더를 끼워 넣어 놓일 자리를 보여줌
// · 목록 위/아래 끝에 커서가 오면 자동 스크롤(센터가 많을 때 필수)
// · 이동 처리는 rAF 로 1프레임 1회만 — 커서를 빨리 흔들어도 렉이 없다
var drag = null; // {key, card, ghost, startX, startY, moved, pointerId}
var suppressClickOn = null; // 드래그로 끝난 카드의 잔여 click 1회 무시
var placeholder = null; // 놓일 자리 표시 요소
var lastPoint = { x: 0, y: 0 };
var moveScheduled = false;
var autoScrollTimer = null;
var DRAG_THRESHOLD = 4;
var EDGE = 44; // 오토스크롤이 걸리는 가장자리 두께(px)
var EDGE_SPEED = 14; // 1틱 스크롤량(px)
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 showPlaceholder(zone) {
if (!drag) return;
if (!placeholder) {
placeholder = document.createElement("div");
placeholder.className = "cpg-drop-placeholder";
}
placeholder.textContent = (labels[drag.key] || drag.key) + " — 여기에 담기";
var list = zone.querySelector(".cpg-dist-items");
(list || zone).appendChild(placeholder);
}
function hidePlaceholder() {
if (placeholder && placeholder.parentNode) placeholder.parentNode.removeChild(placeholder);
}
function autoScrollTick() {
if (!drag || !distList || !distList.getBoundingClientRect) return;
var box = distList.getBoundingClientRect();
if (!box.height) return;
var y = lastPoint.y;
if (y > box.top && y < box.top + EDGE) distList.scrollTop -= EDGE_SPEED;
else if (y < box.bottom && y > box.bottom - EDGE) distList.scrollTop += EDGE_SPEED;
}
function moveGhost(x, y) {
if (!drag || !drag.ghost) return;
drag.ghost.style.left = (x + 14) + "px";
drag.ghost.style.top = (y + 14) + "px";
}
function startDrag(x, y) {
var card = drag.card;
var ghost = card.cloneNode(true);
ghost.className = card.className + " cpg-drag-ghost";
ghost.removeAttribute("style");
ghost.style.width = (card.offsetWidth || 260) + "px";
var badge = document.createElement("span");
badge.className = "cpg-drag-badge";
badge.textContent = "잔여 " + remainFor(drag.key) + "박스";
ghost.appendChild(badge);
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);
if (!autoScrollTimer) autoScrollTimer = setInterval(autoScrollTick, 40);
}
function handleMove() {
moveScheduled = false;
if (!drag) return;
moveGhost(lastPoint.x, lastPoint.y);
var zone = zoneAt(lastPoint.x, lastPoint.y);
clearOver();
hidePlaceholder();
if (zone) {
zone.classList.add("is-over");
showPlaceholder(zone);
if (drag.ghost) drag.ghost.classList.remove("is-invalid");
} else if (drag.ghost) {
drag.ghost.classList.add("is-invalid");
}
}
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 (card) card.classList.remove("is-dragging");
document.body.classList.remove("cpg-dragging");
if (distCard) distCard.classList.remove("is-drop-ready");
if (autoScrollTimer) { clearInterval(autoScrollTimer); autoScrollTimer = null; }
hidePlaceholder();
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("pointerdown", function (e) {
if (e.pointerType === "mouse" && e.button !== 0) return;
var handle = e.target.closest(".cpg-drag-handle");
if (!handle) return;
var card = handle.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, pointerId: e.pointerId };
lastPoint = { x: e.clientX, y: e.clientY };
if (handle.setPointerCapture && e.pointerId != null) {
try { handle.setPointerCapture(e.pointerId); } catch (err) { /* 지원 안 하면 무시 */ }
}
e.preventDefault(); // 텍스트 선택·스크롤 제스처 방지
});
document.addEventListener("pointermove", function (e) {
if (!drag) return;
lastPoint = { x: e.clientX, y: e.clientY };
if (!drag.moved) {
if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD &&
Math.abs(e.clientY - drag.startY) < DRAG_THRESHOLD) return;
startDrag(e.clientX, e.clientY);
}
if (moveScheduled) return;
moveScheduled = true;
if (raf) raf(handleMove); else handleMove();
});
document.addEventListener("pointerup", function (e) {
if (!drag) return;
endDrag(true, e.clientX, e.clientY, e.target);
});
document.addEventListener("pointercancel", function () { endDrag(false, 0, 0, null); });
window.addEventListener("blur", function () { if (drag) endDrag(false, 0, 0, null); });
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && drag) endDrag(false, 0, 0, null);
});
// 드래그하지 않고 클릭한 경우 — 첫 센터를 기본값으로 대화상자를 연다.
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=20260829f" />{% 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=20260829f" />{% 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=20260829f" />{% 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=20260829f" />{% 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=20260829f" />{% 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=20260829f" />{% 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>
+805
View File
@@ -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;
}
+438
View File
@@ -305,3 +305,441 @@
.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: pointer; }
.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); }
/* 드래그 핸들 / 플레이스홀더 / 고스트 배지 */
.cpg-drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 4px;
border-radius: 5px;
color: var(--color-midtone-gray);
cursor: grab;
touch-action: none; /* 모바일에서 스크롤 제스처와 충돌 방지 */
user-select: none;
flex: 0 0 auto;
}
.cpg-drag-handle:hover { background: var(--color-ghost-gray); color: var(--color-rich-black); }
body.cpg-dragging .cpg-drag-handle { cursor: grabbing; }
.cpg-drop-placeholder {
margin-top: 6px;
padding: 8px;
border: 1.5px dashed var(--color-rich-black);
border-radius: 8px;
font-size: 12px;
color: var(--color-rich-black);
background: var(--color-ghost-gray);
animation: cpg-ph-in .15s ease-out;
}
@keyframes cpg-ph-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: none; }
}
.cpg-drag-badge {
position: absolute;
top: -10px;
right: -10px;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-rich-black);
color: var(--color-canvas-white);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.cpg-drag-ghost.is-invalid { border-color: var(--color-callout-red); }
.cpg-drag-ghost.is-invalid .cpg-drag-badge { background: var(--color-callout-red); }
+98
View File
@@ -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; }
}
+156
View File
@@ -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 이벤트를 다시 타지 않는다
});
});
})();
+1
View File
@@ -31,6 +31,7 @@ MODULE_KEYS: tuple[str, ...] = (
"malaysia", "malaysia",
"dispatch", "dispatch",
"project", "project",
"cafe24",
"expense_approver", "expense_approver",
"vacation_approver", "vacation_approver",
) )
+4
View File
@@ -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>
+21
View File
@@ -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:
+404
View File
@@ -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` 으로 돌린다.
+41
View File
@@ -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`.
---
## 백업 / 복구 (안전 절차) ## 백업 / 복구 (안전 절차)
### 백업 ### 백업
+2
View File
@@ -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;
+235
View File
@@ -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;
+43
View File
@@ -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;
+83
View File
@@ -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 등록 확인
+15
View File
@@ -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.
+219
View File
@@ -0,0 +1,219 @@
// 박스 계산 화면(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 }));
// 이동 처리는 requestAnimationFrame 으로 1프레임 지연되므로 한 프레임 기다린다.
const frame = () => new Promise(r => setTimeout(r, 30));
// 포인터 드래그 시뮬레이션.
// jsdom 은 레이아웃이 없어 elementFromPoint 가 항상 null 이므로 드롭 대상을 스텁한다.
function pointer(el, type, x, y) {
el.dispatchEvent(new w.PointerEvent(type, {
bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0, pointerId: 1, pointerType: "mouse",
}));
}
function handleOf(card) { return card.querySelector(".cpg-drag-handle"); }
function dragCardTo(card, target) {
d.elementFromPoint = () => target;
pointer(handleOf(card), "pointerdown", 10, 10);
pointer(d, "pointermove", 60, 60); // 임계값(4px) 초과 → 드래그 시작
pointer(d, "pointermove", 200, 200);
pointer(d, "pointerup", 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");
check("드래그 핸들이 있다", !!handleOf(card));
d.elementFromPoint = () => $(".cpg-dist-center");
// 핸들이 아닌 카드 본문에서는 드래그가 시작되지 않는다
pointer(card.querySelector(".cpg-sum-prod-name"), "pointerdown", 10, 10);
pointer(d, "pointermove", 60, 60);
check("핸들 밖에서는 드래그 안 됨", !card.classList.contains("is-dragging") && !$(".cpg-drag-ghost"));
pointer(handleOf(card), "pointerdown", 10, 10);
pointer(d, "pointermove", 60, 60);
await frame();
check("핸들에서 4px 이상 움직이면 드래그 시작", card.classList.contains("is-dragging"));
check("고스트(복제 카드) 생성", !!$(".cpg-drag-ghost"));
check("고스트에 잔여 배지", text(".cpg-drag-badge") === "잔여 2박스", text(".cpg-drag-badge"));
check("드래그 중 대상 센터 강조", $(".cpg-dist-center").classList.contains("is-over"));
check("놓일 자리 플레이스홀더 표시", !!$(".cpg-drop-placeholder"));
check("플레이스홀더에 대상 이름", text(".cpg-drop-placeholder").includes("미라네 1호 세트"),
text(".cpg-drop-placeholder"));
// 대상 밖으로 나가면 금지 표시
d.elementFromPoint = () => $(".cpg-calc-card");
pointer(d, "pointermove", 400, 400);
await frame();
check("대상 밖이면 고스트가 금지 표시", $(".cpg-drag-ghost").classList.contains("is-invalid"));
check("대상 밖이면 플레이스홀더 제거", !$(".cpg-drop-placeholder"));
d.elementFromPoint = () => $(".cpg-dist-center");
pointer(d, "pointermove", 200, 200);
await frame();
pointer(d, "pointerup", 200, 200);
d.elementFromPoint = () => null;
check("드롭 후 고스트 제거", !$(".cpg-drag-ghost"));
check("드롭 후 플레이스홀더 제거", !$(".cpg-drop-placeholder"));
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) Esc 로 드래그 취소
d.elementFromPoint = () => $(".cpg-dist-center");
const card4 = $(".cpg-sum-prod.is-draggable");
pointer(handleOf(card4), "pointerdown", 10, 10);
pointer(d, "pointermove", 80, 80);
d.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
check("Esc 로 드래그 취소", !$(".cpg-drag-ghost") && $("#cpg-dlg").hidden === true);
pointer(d, "pointerup", 200, 200);
d.elementFromPoint = () => null;
// (4) 드래그 없이 클릭한 경우
$(".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);
})();
+39
View File
@@ -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()