75 Commits

Author SHA1 Message Date
king 13ac11cc0a ui(expense): 총 건수/총 금액 카드 제거 + 경비 내역 건수·합계 글씨 확대
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:25:10 +09:00
king 069d998c9d feat(expense): 경비 내역 월 선택 필터 + 월 합계, 월별 집계 버튼 제거
- 상단 "월별 집계" 버튼 삭제
- 경비 내역: 월 선택(input month, 변경 시 자동 조회) 기본 이번 달
- 선택 월(사용일 기준)만 표시 + 해당 월 합계금액 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:22:58 +09:00
king e782cb4ea7 feat(expense): 영수증 이미지 업로드 시 가로 1000px 초과면 축소 + JPEG q80
- 가로 > 1000px 이미지: 비율 유지 1000px 축소 후 JPEG 품질 80 재인코딩
- EXIF 회전 보정, 알파/팔레트 → RGB 변환, 확장자 .jpg 로 통일
- 1000px 이하/비이미지/처리 실패 시 원본 그대로 저장
- 업로드를 메모리 읽기 후 처리 방식으로 변경(20MB 제한 유지)
- requirements: pillow>=10.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:12:09 +09:00
king 5582c72467 feat(expense): 영수증 파일명 규칙 변경 + 등록 폼 필수 검증
- zip 첨부 파일명: 날짜_이름_분류_결제수단_금액_가맹점.확장자 (날짜=사용일)
  - 가맹점 없으면 생략, 금액 쉼표 표기, 금지문자 정리
- db.approved_attachments 에 category/method/amount/merchant 추가
- 등록 폼: 사용일/분류/결제수단/금액/가맹점 필수 — 누락 시 경고창 + 포커스
  (가맹점 input required, 금액 1원 이상)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:08:56 +09:00
king b678d7c37f ui(expense): 승인완료 엑셀 다운 버튼 제거 + zip 버튼 검정(primary)
- 엑셀은 zip에 동봉되므로 별도 버튼 삭제
- 전부 다운로드(zip) 버튼 erp-btn-primary(검정 배경/흰 글씨)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:01:54 +09:00
king 5b0fe2f1a1 style(expense): 승인 목록 엑셀 서식 — 제목행 파랑/금액 쉼표/열너비 자동
- 제목행: 파랑(4472C4) 배경 + 흰 굵은 글씨 + 가운데 정렬
- 금액/합계금액: #,##0 쉼표 표시형식
- A~K 열너비 내용 기준 자동(한글/전각 2칸 계산)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:57:21 +09:00
king 6ae0d90b97 feat(expense): 전부 다운로드 zip에 승인 목록 엑셀 동봉
- _build_approved_xlsx 헬퍼로 엑셀 생성 로직 추출(export/zip 공용)
- zip 최상단에 승인목록_{month}.xlsx 포함 + 첨부 파일들

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:53:40 +09:00
king 09e2f06a14 fix(expense): zip 버튼 라벨 변경 + 경로 폴백/진단 헤더
- "첨부 일괄(zip)" → "전부 다운로드(zip)"
- stored_path 깨진 경우 업로드루트/item_id/파일명 으로 폴백 탐색
- X-Attachment-Stats 헤더(total/added/missing)로 빈 zip 원인 진단

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:50:49 +09:00
king a107b06826 feat(expense): 승인완료 메뉴 — 전 직원 월별 + 엑셀/첨부 zip
- /expense/approved: 승인자/관리자 전용, 월 선택 + 전 직원 승인완료 항목
- 직원별 승인 금액 합계표 + 총합
- 엑셀 다운(리스트 + 직원별합계 시트)
- 첨부 일괄 zip: 파일명 yy년 mm월 dd일(요일)_이름.확장자 (업로드일 기준)
- store: APPROVED_STATUSES(승인,정산완료) / db: list_approved, approved_attachments

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:46:41 +09:00
king 506db5ff07 feat(expense): 분류 설정 메뉴를 승인자에게도 노출
- /expense/settings 및 분류 CRUD 가드 관리자 → 승인자(expense_approver|admin)
- index 설정 버튼 is_approver 조건으로 노출
- 미사용 _require_admin 헬퍼 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:28:26 +09:00
king 0b04a05b26 feat(expense): 관리자 분류 설정 메뉴 추가 (동적 분류)
- CategoryStore: 분류 항목 JSON 저장소(DATA_DIR/expense_categories.json)
- 관리자 설정 페이지(/expense/settings) + 분류 CRUD API
- _normalize 분류 고정목록 검증 제거 — 추가 분류 즉시 등록 폼/집계 반영
- index/reports 분류를 동적 목록으로 교체

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:22:01 +09:00
king ec8c9edcc9 fix(expense): 라운드 제거 대상은 .erp-table-wrap + 클립 아이콘 22px 강제
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 02:14:21 +09:00
king 7ff3500844 ui(expense): 내역 표 첫 행 모서리 라운드 제거 + 첨부 아이콘 확대
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 02:11:44 +09:00
king 84624baf8e ui(expense): 경비 내역 표 가운데 정렬 + 동작 헤더 제거 + 한 줄(너비 자동)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 02:08:22 +09:00
king d728c55e2a fix(expense): 요약카드 70px 강제(!important) + 요약줄·등록폼 간격 추가
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:40:22 +09:00
king 55c98b6b95 fix(expense): 요약카드 70px — grid-auto-rows 로 행 높이 고정(stretch 무력화)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:37:16 +09:00
king ca53042eb4 ui(expense): 상단 요약카드 70px + 경비등록(좌)/경비내역(우) 2단 배치
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:31:32 +09:00
king e2c6a50b46 feat(vacation): 관리자는 모든 상태(승인/취소 포함) 휴가 삭제 가능
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:27:10 +09:00
king cb43fe1257 fix(cupang): itemcode 조회 오류를 로그+API+화면에 노출 (원인 진단)
- ItemcodeReader.last_error 추가, _run 실패 시 logger.exception + 메시지 보존
- /api/products/all 응답에 error/count 추가
- products 화면: 결과 0 일 때 조회오류/사유 표시 (그냥 '결과 없음' 대신)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:16:54 +09:00
king 8f3761aeae ui(shell): 브랜드 'DBX ERP System' 통일 + 사이드바 접힘 시 아이콘 아래 메뉴명 표시
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:21:05 +09:00
king d30dcbada4 ui(cupang): 달력 행 유동화(6주 균등 분할) — 1883x938 세로 스크롤 제거
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:15:31 +09:00
king 9f361c4e6a ui(shell): 뷰포트(1883x938) 고정 — 문서 세로 스크롤 제거
- erp 셸 height:100vh + body overflow:hidden, .erp-page 가 남은 높이 채우고 내부 스크롤
- 휴가 달력: 6주 행이 가용 높이 균등 분할(유동), 날짜 셀 height:100%, 선택일 패널 내부 스크롤
- 쿠팡 출고 폼: 라인 패널 고정 900px 높이 → 가용 높이 채움
- CORM/Order 는 별도 창이라 해당 없음

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:12:41 +09:00
king fde2c9c2e9 ui(vacation): 휴가 bar 시작 위치 38px 로 내려 날짜 pill 가림 방지
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 09:01:19 +09:00
king 162e1d22a9 ui(cupang,vacation): 달력 년월 왼쪽 정렬(쿠팡) + 년월·이동버튼 18pt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 08:58:35 +09:00
king 1806c75ecb ui(vacation): 오늘 날짜 pill 배경을 요일색(빨강/파랑/검정)에 맞춰 흰글씨
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 08:54:11 +09:00
king 8ab5223790 feat(vacation): 상세 — 승인은 취소처리, 미승인(작성중/제출/반려)은 삭제(hard)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 08:50:02 +09:00
king fa1027fa1b feat(time): 프로젝트 전체 시간 KST(UTC+9) 통일
- app/timezone.py 신설: KST 고정오프셋(+9, DST 없음) + now_kst/today_kst/to_kst_iso
- DB 직렬화 created_at/updated_at/decided_at/uploaded_at 표시를 UTC→KST 로 변환
  (expense/cupang/vacation db.py)
- _now_iso(JSON 저장) KST 기준
- 라우터 date.today()/datetime.now() → today_kst()/now_kst() (달력 '오늘'/엑셀 파일명)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 08:48:28 +09:00
king 32fc12e283 ui: 검정 배경 요소 글자 흰색 강제 (a:visited 가 덮어쓰던 문제 차단, 전역)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 08:39:46 +09:00
king 8562c4f65d ui(vacation): 날짜 숫자 20px + 칸 높이 125px + 신청 버튼 글자 흰색
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 08:36:21 +09:00
king bec9c5e5a6 ui(vacation): 달력 날짜 정사각형 셀 + 큰 글자 + 안쪽 테두리 + bar 오버레이
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 08:33:15 +09:00
king 8c6539d6cb ui(sidebar): 아이콘/글자 크기 확대 + 운영·관리 그룹 묶음 정렬
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 08:29:28 +09:00
king 269e779129 feat(vacation): 휴가 관리 모듈 — 월간 달력 bar/연차·반차 신청/승인 워크플로/공휴일·연차 설정
- vacation_db 전용 저장소(VacationDBStore), VACATION_DB_URL 미설정 시 '설정 필요' 안내
- 월간 달력: 구글식 휴가 bar(주 단위 lane 배정), 일/공휴일 빨강·토요일 파랑
- 신청 폼: 종류/기간/시작·종료 구분(종일/오전/오후)/사유 + 예상 일수 미리보기
- 일수 계산은 서버(compute_days)에서 주말+공휴일(is_red) 제외 재계산
- 워크플로: 작성중/반려→제출→승인|반려, 취소 soft delete, 본인만 수정(작성중/반려)
- 권한: vacation(접근)/vacation_approver(승인), admin 통과. 설정은 admin 전용
- 공휴일(vacation_holidays) DB 기반 provider + settings CRUD, 연차잔여(vacation_balances)
- 엑셀 내보내기(openpyxl), scripts/sql/vacation_db_init.sql(멱등, 2026 공휴일 seed)
- main.py 등록, 메뉴 ready 전환, docs/.env.example/CLAUDE.md 갱신

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 08:20:20 +09:00
king 3cf22513ca ui(cupang): 출고 리스트 상태 배지 제거 (상태 항목 폐지)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 07:14:19 +09:00
king 33ce9482dd fix(cupang): 출고 툴팁 수량에 '개' 단위 표기
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 07:00:36 +09:00
king ce1014e6bc fix(cupang): 출고 툴팁 500 에러 — s.items(dict 메서드 충돌) → tip_items
Jinja 의 s.items 는 dict 키가 아닌 .items() 메서드를 반환해 tojson 실패.
컨텍스트 키를 tip_items 로 변경.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 06:58:32 +09:00
king 652e53c3e8 feat(cupang): 선택일 출고 항목 hover 툴팁(제품명+수량, 커서 따라감)
- index sel_shipments 에 품목 요약(name/qty) 첨부
- 리스트 항목 hover 시 커서 왼쪽에 따라다니는 툴팁 표시
- 캐시 버전 m

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 06:55:52 +09:00
king 5c7730d4a1 ui(cupang): 달력 상단 설정 버튼을 달력 컬럼 오른쪽 끝에 정렬 (Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>) 2026-05-30 06:41:11 +09:00
king 57f0c3426f refactor(cupang): UI에서 제거된 기능의 죽은 코드 정리
- 엑셀 내보내기 전체 제거: /export, /{id}/export, _build_xlsx,
  _flatten_for_sheet, shipments_for_export, SHEET_COLUMNS
- 미사용 라우트 제거: /api/calendar, /api/products/search, /api/box-calc,
  /{id}/status(상태변경 UI 삭제됨)
- 미사용 db 메서드 제거: deactivate_box_rule, get_box_rule, box_rule_map
- form.html window.CPG + cpg-search-pop, cupang.js CFG, 검색 드롭다운 CSS 제거
- 템플릿 미사용 statuses 컨텍스트 제거, router import 정리
- 캐시 버전 k

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 06:37:51 +09:00
king bcbf3de24c ui(cupang): 달력 상단 엑셀 버튼 삭제 + 설정 버튼 이름/우측정렬
- 엑셀(N월) 버튼 제거
- 제품명 설정 / 입고센터 관리 / 박스 입수량 설정 로 명칭 통일, 우측 정렬
- 캐시 버전 j

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 06:29:22 +09:00
king 2f16457ac6 ui(cupang): 품목 라인 체크박스 칸 가운데 정렬 (Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>) 2026-05-30 06:25:07 +09:00
king fdabeec4e3 ui(cupang): 품목 라인 번호열을 체크박스로 + 선택 라인 삭제 + 전체선택
- # 열 → 체크박스(헤더 전체선택, indeterminate 지원)
- "라인 삭제" → "선택 라인 삭제": 체크된 행 삭제(최소 1줄 유지)
- 캐시 버전 h

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 06:23:16 +09:00
king f25948185b ui(cupang): 박스이름 180px로 맞춰 박스당 입수량을 제품코드와 세로 정렬 (Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>) 2026-05-30 06:14:54 +09:00
king 7d39c2edec ui(cupang): 입수량 규칙 목록 입수량에 개 단위 표기 (Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>) 2026-05-30 06:12:47 +09:00
king 90e286dc4d ui(cupang): 박스룰 폼 3행 배치 + 목록 컬럼 정리/하드삭제
- 폼: 1행 제품명180/제품코드100, 2행 박스이름/박스당입수량, 3행 메모(전체폭)
- 목록 순서 제품명·제품코드·박스명·입수량·메모, 상태열/비활성 제거
- 삭제 버튼=완전삭제(delete_box_rule, 참조 라인 box_rule_id NULL 처리)
- 캐시 버전 f

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 06:11:57 +09:00
king 2e3ea610b7 ui(cupang): 박스 입수량 2열 배치 + 고정폭 + 입수량 "개" 단위
- 추가/수정 480px(좌), 입수량 규칙 900px(우)
- 제품명/제품코드/박스이름 입력 140px, 박스당 입수량 60px + "개" 접미
- 캐시 버전 e

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 06:03:02 +09:00
king c11c5733de ui(cupang): 박스 입수량 폼 제품명 드롭다운 + 라벨 정리
- 제품명*(등록 제품 드롭다운, 코드 오름차순) 선택 시 제품코드/스냅샷 자동
- 라벨: 제품명* / 제품코드 / 박스이름 / 박스당 입수량*
- 캐시 버전 d

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 05:52:57 +09:00
king 478cde8caf fix(cupang): 헤더 입력 185px — erp.css min-width:240 오버라이드
진짜 원인: erp.css .erp-input{min-width:240px} 가 width:185 를 눌러 240 고정.
헤더 입력 규칙에 min-width:185px 추가로 해결. 캐시 버전 c 로 bump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 05:43:37 +09:00
king 574c053077 fix(ui): 정적 CSS/JS 캐시 무효화 쿼리(?v=) 추가
브라우저가 옛 cupang.css/erp-shell.css 를 캐시해 최근 레이아웃 변경
(헤더 185px, 콘텐츠 1550px 등)이 반영되지 않던 문제. 링크에 버전 쿼리 추가.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 05:40:44 +09:00
king 800fbd1da4 ui(cupang): 헤더 입력 185px 강제 + 라인 추가/삭제 버튼 제목행 우측
- 헤더 입력 185px 특이도 강화(.cpg .cpg-header-grid .erp-field>input)
- 라인 추가/삭제 버튼을 "품목 라인" 제목행 우측으로 이동(하단 footer 제거)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 05:38:33 +09:00
king 2607e0610f ui: 콘텐츠 영역 1550px, 쿠팡 헤더 입력란 185px 고정
- erp-page max-width 1280 -> 1550 (우측 콘텐츠 사용 영역)
- 공통 헤더 작성일/출고일/센터입고일/입고센터/출고방식/작업자 185px 컬럼 고정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 05:32:58 +09:00
king 150335cf87 ui(cupang): 폼 프레임 크기 고정 + 라인 추가/삭제 버튼 하단 우측
- 공통 헤더 450px, 품목 라인 900x900 내부 스크롤
- 저장/취소 위 구분선 제거
- 라인 추가 + 라인 삭제 버튼을 품목 라인 하단 우측에 배치
- 행별 ✕ 삭제 제거 → "라인 삭제"가 마지막 라인 삭제(최소 1줄 유지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 05:24:49 +09:00
king d4e823f1fa ui(cupang): 저장·취소 버튼을 공통 헤더 카드 하단에 고정 배치
품목 라인 수가 늘어도 좌측 헤더 카드 하단에 고정(우측 컬럼 높이와 무관)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 05:13:41 +09:00
king b75fda5901 ui(cupang): 신규/수정 폼 저장·취소 버튼 하단 sticky 고정
라인 수가 늘어도 저장/취소 위치가 밀리지 않도록 position:sticky bottom

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 05:11:05 +09:00
king d9ea7550b0 feat(cupang): 출고방식 파렛트, 상태/문서번호 폼 제거, 폼·상세 레이아웃 개편
- 출고방식 선택지에 "파렛트" 추가
- 신규/수정 폼에서 상태·문서번호 입력 제거 (status는 작성중 기본 + 취소처리로만 변경)
- document_no DB 컬럼 제거: init.sql 반영 + 기존 DB용 002 드롭 마이그레이션(승인 후 실행)
- 폼: 공통 헤더(좌) / 품목 라인(우) 2열 배치
- 상세: 엑셀·상태변경 버튼 삭제, ◀◀ 달력(검정) 좌측, 수정 우측 정렬, 문서번호 표시 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 05:07:21 +09:00
king 4113a034f4 ui(cupang): 입고센터 페이지 좌우 배치 + 목록 가나다순/900px 스크롤
- 추가 폼에서 정렬순서 입력란 제거(좌측), 센터 목록 우측
- 센터 목록 이름 오름차순, 프레임 900px 고정 내부 스크롤, 1열 행
- 행 편집은 이름 저장 + 활성/비활성 + 삭제 (정렬 입력 제거)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 04:39:47 +09:00
king f9c1dc140b ui: 입고센터 드롭다운 가나다순, 사이드바 hover 개선, 달력 버튼 강조
- 출고 폼 입고센터 드롭다운 이름 오름차순 정렬
- 사이드바: is-active 메뉴는 hover 무변화, 그 외 진한 회색(#d9d9d9)
- 설정/센터/박스 페이지 "← 달력" → "◀◀ 달력", 검정 배경 흰 글씨(primary)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 04:35:35 +09:00
king 17dfd23cfc ui(cupang): 설정 프레임 높이 950->900 (Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>) 2026-05-30 04:24:24 +09:00
king 45f6b3b174 ui(cupang): 제품명 목록/드롭다운 정렬을 제품코드 오름차순으로
폼 제품명 드롭다운 + 설정 등록목록 모두 list_products 사용 → ORDER BY product_code ASC

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 04:21:48 +09:00
king 867bc510da ui(cupang): 박스입수량 라벨/겹침, 입고센터 컴팩트 재디자인, 출고 완전삭제
- 박스 입수량 폼: 제품명(스냅샷)→제품명, 헤더그리드로 교체해 입력란 겹침 해소
- 입고센터: 추가 폼 슬림화 + 센터 목록 2열 그리드(한 줄/센터)로 압축
- 출고 상세: 완전삭제 버튼 추가(/{id}/hard-delete, 라인 CASCADE). 취소 처리는 outline 로

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 04:16:24 +09:00
king 69b84db4bb ui(cupang): 설정 프레임 크기 고정 + 라벨 변경 + 코드 우측 타입 제거
- "itemcode_db 상품" → "미라네 주방 상품"
- 미라네 주방 상품 500x950, 등록된 제품명 600x950 고정, 내부 스크롤
- 좌측 리스트 코드 옆 type(낱개/세트) 표기 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 03:57:32 +09:00
king 1d33761003 feat(cupang): 설정 화면 2열 개편 — itemcode 다중선택 등록 + 활성/삭제
- 수동 등록 카드 제거
- 왼쪽: itemcode_db 전체 목록(낱개+세트) 다중선택, 등록된 항목 진회색+흰글씨
  /cupang/api/products/all + /cupang/products/bulk(JSON 일괄 upsert)
- 오른쪽: 등록 제품명 목록 — 제품명/코드/상태 + 활성·비활성 토글 + 완전삭제
  /products/{id}/active (토글), /products/{id}/delete (hard delete)
- ItemcodeReader.list_all 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 03:46:35 +09:00
king 96e2c49506 docs(cupang): itemcode_db 실제 스키마 반영 (single_items/set_items UNION 검색 SQL)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 03:33:08 +09:00
king 34c7e2f964 feat(cupang): 제품명 카탈로그 설정 + 달력 공휴일 색상 + 폼 UI 개선
- 설정 화면 추가: /cupang/products — itemcode_db 검색 등록 + 수동 등록.
  제품명 드롭다운 소스. cupang_products 테이블 신규(init.sql 멱등 재실행 필요)
- 폼 품목라인: 제품명 드롭다운 선택 → 제품코드 자동 입력
- 라인 컬럼 순서 제품명·제품코드·수량·입수량·박스계산·메모
- 제품코드 140px, 수량·입수량 60px 폭 고정
- 공통 헤더 입력란 겹침 수정(min-width:0, width:100%, box-sizing)
- 달력: 일/공휴일 빨강, 토 파랑, 글자 크기 확대. 한국 공휴일 모듈(holidays.py)
- document_no null → "None" 출력 버그 수정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 03:24:05 +09:00
king 9e707be5ac feat(cupang): 쿠팡 밀크런 모듈 추가 (출고 달력/박스계산/입고센터)
- app/modules/cupang: router/db/store/itemcode + 템플릿 5종
- cupang_db 전용(JSON 폴백 없음). CUPANG_DB_URL 미설정 시 안내 페이지
- 월간 달력 + 출고 묶음(헤더+라인), 박스 입수량 자동계산(서버 재계산)
- 입고센터 관리(사용중 soft delete), 박스규칙 upsert, 엑셀 내보내기
- 상품 검색은 itemcode_db 읽기 전용(미설정 시 수동 입력)
- MODULE_KEYS/메뉴/아이콘/라벨 동기화, scripts/sql/cupang_db_init.sql(멱등)
- app/data/ gitignore 추가(PII)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:47:32 +09:00
king 6918426264 feat(expense): 첨부 뷰어 패닝/엑셀 + 체크박스 일괄작업 + 정렬
[첨부 뷰어]
- 펜 주석 기능 제거 (요구사항 정정).
- 이미지: 마우스 드래그로 패닝, 축소/확대/100%/맞춤 줌 도구.
- 엑셀 (xlsx/xls/xlsm/csv/ods): SheetJS lazy load(CDN) 후 시트 탭으로 표시.
- 기타 파일: 다운로드/새 창 링크.

[경비 내역 / 승인 대기 공통]
- 행 단위 첫 컬럼 체크박스 + thead 전체선택 체크박스.
- 표 제목 상단에 일괄 동작 버튼 (선택 0이면 disabled).
- 카드 헤더와 행 모두 vertical-align: middle 로 일직선 정렬.

[경비 내역]
- 첨부 추가 버튼의 📎 아이콘 제거. "기타" → "기타 파일".
- 행 단위 "제출" 버튼 제거 → 상단 "선택 항목 제출" 단일화.
- 행 단위 첨부 보기 아이콘(📎) 추가 — 클릭 시 ErpAttachViewer 호출.

[승인 대기]
- 일괄 승인 (체크 후 상단 버튼).
- 반려는 항목별 유지 (사유 필수라 일괄 부적합).

[CSS]
- erp-table tbody td vertical-align top → middle (admin 포함 일관).
2026-05-29 03:57:18 +09:00
king 151de492ad feat(expense): 첨부 뷰어 모달 — 이미지 미리보기 + 펜 주석
승인 대기 페이지의 "첨부" 텍스트 링크를 클립 아이콘 버튼으로 교체.
클릭 시 모달 팝업으로 이미지 미리보기 + 마우스 펜 주석 + 다운로드.

뷰어 컴포넌트 (전역, 다른 페이지에서도 재사용 가능):
- 좌측: 첨부 목록 (썸네일/메타)
- 우측: 캔버스 스택 (img + 투명 canvas)
- 도구: 펜/지우개, 색상 4종, 굵기 3종, 전체 지우기, 합성 PNG 저장, 원본 열기
- 이미지 외 파일은 다운로드 링크만 표시
- ESC/배경 클릭으로 닫기, pointer events 지원

전역 노출: window.ErpAttachViewer.{open, openFor, close}
2026-05-29 03:40:03 +09:00
king d4af2dc064 ui(expense): 첨부 버튼을 동작 셀에 직접 노출 (화살표 토글 제거)
기존: 행마다 ▸ 화살표 클릭 → 상세 영역 펼침 → 영수증/기타 추가
변경: 동작 셀에 📎영수증 / 📎기타 버튼 항상 노출.
      첨부 목록은 메인 행 바로 아래 인라인으로 항상 표시.

페이지 로드 시 모든 항목의 첨부 자동 fetch.
업로드 input 위치 변경에 맞춰 JS 핸들러 보정.
2026-05-29 03:33:41 +09:00
king d2c1dfa2a0 fix(expense-db): _serialize 에서 decided_at 도 ISO 문자열로 변환
신규 워크플로 컬럼 decided_at (TIMESTAMPTZ) 가 datetime 그대로 템플릿에
전달돼 'datetime.datetime' object is not subscriptable 로 500 발생.
created_at/updated_at 과 같이 isoformat 변환 대상에 포함.
2026-05-29 03:29:16 +09:00
king a4eaf9b770 fix(expense-db): 앱 부팅 시 DDL 실행 제거 + lazy pool open
PostgreSQL 15+ 는 public 스키마 CREATE 권한을 PUBLIC 역할에서 회수했기 때문에
expense_app (앱 전용 비특권 계정) 으로 CREATE TABLE 시도하면
"permission denied for schema public" 으로 컨테이너 부팅 실패.

스키마 책임 분리:
- DDL: scripts/sql/expense_db_init.sql + expense_db_002_workflow_attachments.sql
       (superuser 가 사전 적용)
- 런타임: SELECT/INSERT/UPDATE/DELETE 만

ConnectionPool 도 open=False + lazy open 으로 변경. DB 가 잠시 끊겨도
컨테이너가 죽지 않게 함.
2026-05-29 03:22:08 +09:00
king 57ea186061 fix(compose): postgres_default 외부 네트워크 명시 연결
dbx-main 이 postgres-db (postgres_default 네트워크 소속) 를 DNS 로 찾을 수
있도록 docker-compose.yml 에 external network 선언. 그렇지 않으면 compose
재기동 시마다 docker network connect 를 수동으로 다시 해줘야 함.
2026-05-29 03:19:22 +09:00
king 004b2dff1f feat(auth): 승인자 권한 보유자는 해당 모듈 접근 자동 허용
expense_approver 권한만 가진 관리자가 본인 경비를 등록/제출할 수 있도록
has_module() 에서 <module>_approver 보유 시 <module> 접근도 허용.

APPROVER_KEYS 자체에는 재귀 적용하지 않음 (vacation_approver 가
expense_approver 를 부여하는 일은 없음).
2026-05-29 03:01:03 +09:00
king 550867ba0c feat(expense+admin): 결재 워크플로 + 첨부 + 집계 + 엑셀 + 사용자 직접 등록
[admin]
- POST /api/users 사용자 직접 등록 (이메일만으로). UserStore.create_user 추가
- admin.html: 등록 폼 + 삭제 버튼 + 한글 라벨 + 신규 권한 표시
- 신규 권한 키: expense_approver, vacation_approver (APPROVER_KEYS)
- is_approver(user_rec, kind) 헬퍼

[expense 워크플로]
- 상태 전이: 작성중 → 제출 → 승인/반려 → 정산완료
- API: /api/items/{id}/{submit,revert,approve,reject,settle}
- 권한: submit/revert=owner, approve/reject/settle=expense_approver 또는 admin
- expense_items 컬럼: approver_email, decided_at, reject_reason
- 작성중/반려 상태에서만 수정/삭제/첨부 가능

[expense 첨부]
- 영수증/기타 (kind: receipt|other). 최대 20MB, 확장자 화이트리스트
- 저장: DATA_DIR/uploads/expense/{item_id}/{att_id}_{filename}
- expense_attachments 테이블 (CASCADE on item delete)
- API: 업로드/목록/다운로드/삭제

[expense 집계]
- /expense/reports: 연도별 월×카테고리 피벗 표

[expense 엑셀]
- GET /expense/api/export.xlsx?from&to&scope=mine|all
- scope=all 은 승인자/관리자만

[DDL]
- expense_db_init.sql: 신규 컬럼/테이블 포함 (멱등)
- expense_db_002_workflow_attachments.sql: 운영 DB 마이그레이션용

[deps]
- openpyxl>=3.1, python-multipart>=0.0.20

[docs]
- DATABASES.md: 워크플로 다이어그램, attachments 테이블, 마이그레이션
- PROJECT_OVERVIEW.md: 권한 키 표
- DEPLOYMENT.md: DATA_DIR/uploads 안내
2026-05-29 02:52:44 +09:00
king afacc9a7db fix(expense-db): psql 변수가 DO 블록 내에서 치환되지 않는 문제 수정
dollar-quoted ($$...$$) 안의 :'app_password' 는 psql 이 치환하지 않아
서버에서 syntax error 발생. DO 블록을 제거하고 \gexec 로 동적 SQL 생성.

- CREATE ROLE: 미존재 시에만
- ALTER ROLE: 비밀번호 항상 동기화
2026-05-29 02:24:26 +09:00
king 9c58b022e2 feat(expense): 개인경비 모듈 + ERP 좌측메뉴 셸 + expense_db 연결
- ERP 셸: 좌측 사이드바 + 상단 헤더 + 콘텐츠 영역(erp_base.html)
- 분기: 슈퍼관리자 → 업무모듈 카드, 일반사용자 → ERP 메인(erp_home.html)
- 신규 라우트: /modules (슈퍼관리자 모듈 카드 재진입)
- 개인경비 모듈: app/modules/expense/ (라우터/저장소/템플릿)
  - JSON 저장소(ExpenseStore) + PostgreSQL 저장소(ExpenseDBStore)
  - 팩토리: EXPENSE_DB_URL 있으면 DB, 없으면 JSON 폴백
- DB: scripts/sql/expense_db_init.sql (멱등 DDL, postgres-db 컨테이너)
  - expense_db, 역할 expense_app, 테이블 expense_items, 인덱스/트리거
- 마이그레이션: scripts/migrate_expense_json_to_db.py (멱등, --dry-run)
- 의존성: psycopg[binary,pool]>=3.2
- 환경변수: EXPENSE_DB_URL 추가
- 문서: CLAUDE.md 작업 기준 + docs/{PROJECT_OVERVIEW,SERVER_ARCHITECTURE,DATABASES,DEPLOYMENT}.md
  - main-app 배포 경로 /opt/www/main 고정 명시
  - 위험 명령(DROP/TRUNCATE/rm -rf/volume 삭제) 사용자 승인 정책
2026-05-29 02:21:03 +09:00
king d7b6c7d029 chore: add DESIGN.md, brand logo, caveman code rules
- DESIGN.md: 모노크롬 블루프린트 UI 스타일 레퍼런스
- app/static/[Originals]/dbx-logo.png: 브랜드 로고 원본
- skills/code.rules.md: 코드 작성 규칙
2026-05-29 02:20:40 +09:00
65 changed files with 11183 additions and 67 deletions
+25
View File
@@ -15,3 +15,28 @@ PUBLIC_BASE_URL=https://dbx.no1king.freeddns.org
CS_ORDER_URL=/corm/ CS_ORDER_URL=/corm/
# 비워두면 같은 도메인의 /orderlist/ 경로(NPM 리버스 프록시)로 자동 연결됨 # 비워두면 같은 도메인의 /orderlist/ 경로(NPM 리버스 프록시)로 자동 연결됨
CUSTOMER_ORDER_LIST_URL=/orderlist/ CUSTOMER_ORDER_LIST_URL=/orderlist/
# ─── 개인경비 모듈 (expense_db) ───
# 설정하면 PostgreSQL 사용, 미설정 시 DATA_DIR/expense.json 사용.
# DB/역할 생성: scripts/sql/expense_db_init.sql 참고.
# EXPENSE_DB_URL=postgresql://expense_app:replace-me@postgres-db:5432/expense_db
# ─── 쿠팡 밀크런 모듈 (cupang_db) ───
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
# DB/역할/스키마/센터 seed 생성: scripts/sql/cupang_db_init.sql 참고.
# CUPANG_DB_URL=postgresql://cupang_app:replace-me@postgres-db:5432/cupang_db
# ─── 휴가 관리 모듈 (vacation_db) ───
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
# DB/역할/스키마/공휴일 seed 생성: scripts/sql/vacation_db_init.sql 참고.
# 권한키: vacation(접근) / vacation_approver(승인·반려). admin 은 항상 통과.
# VACATION_DB_URL=postgresql://vacation_app:replace-me@postgres-db:5432/vacation_db
# ─── 상품 검색 (itemcode_db 읽기 전용) ───
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
# 미설정 시 검색 비활성 → 수동 등록만 가능.
# itemcode_db 실제 테이블: single_items(낱개) / set_items(세트)
# 컬럼: item_code, sabangnet_code, name
# 읽기 전용 역할 itemcode_ro 를 먼저 생성하고(아래 DSN), 낱개+세트 UNION 검색 SQL 사용:
# ITEMCODE_DB_URL=postgresql://itemcode_ro:replace-me@postgres-db:5432/itemcode_db
# ITEMCODE_SEARCH_SQL=SELECT item_code AS code, name AS name, '낱개' AS type FROM single_items WHERE item_code ILIKE %(q)s OR name ILIKE %(q)s UNION ALL SELECT item_code AS code, name AS name, '세트' AS type FROM set_items WHERE item_code ILIKE %(q)s OR name ILIKE %(q)s ORDER BY code ASC LIMIT %(limit)s
+3
View File
@@ -2,6 +2,9 @@
.env .env
.env.local .env.local
# 런타임 데이터 (사용자 이메일/경비 등 PII — 커밋 금지. 운영은 DATA_DIR 볼륨)
app/data/
# 로컬 전용 스크립트 (자격증명 포함 가능) # 로컬 전용 스크립트 (자격증명 포함 가능)
push-gitea.bat push-gitea.bat
run-local.bat run-local.bat
+106
View File
@@ -0,0 +1,106 @@
# main-app ERP 프로젝트 작업 기준
> 이 문서는 Claude Code가 이 저장소에서 작업할 때 가장 먼저 확인하는 기준 문서입니다.
> 작업 시작 전, 아래 "반드시 먼저 읽을 문서"를 모두 확인한 뒤 작업을 시작합니다.
---
## 반드시 먼저 읽을 문서
Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아래 문서를 순서대로 읽고 맥락을 확보**한 뒤 작업한다.
1. `docs/PROJECT_OVERVIEW.md` — 프로젝트 정의, 기능 범위, 연동 대상
2. `docs/SERVER_ARCHITECTURE.md` — 서버 구성도와 네트워크 흐름
3. `docs/DATABASES.md` — PostgreSQL DB 구성과 명명 규칙
4. `docs/DEPLOYMENT.md` — 배포 경로, 서비스 실행 방식, 복구 절차
문서 간 내용이 충돌하면 위의 우선순위(1 → 4)를 따른다.
---
## 프로젝트 한 줄 정의
`main-app`은 DBX ERP 시스템의 **메인 프로젝트(허브)** 이다.
담당 영역:
- 주문관리
- 상품코드 매칭
- 재고관리
- CS관리
- 반품관리
- 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등)
- 개인경비 (`app/modules/expense/`, `expense_db`)
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
상세는 `docs/PROJECT_OVERVIEW.md`.
---
## 개발 원칙
- 기존 코드를 수정하기 전 관련 파일을 먼저 읽고 구조를 파악한다.
- 위험 명령은 **반드시 사용자 확인 후** 실행한다 (아래 "위험 명령" 절 참고).
- `.env`, API 키, DB 비밀번호, OAuth Secret, 토큰은 절대 Git에 올리지 않는다.
- 신규 DB가 필요하면 **승인 요청 후** 생성하며, DB명은 반드시 `_db`로 끝낸다 (예: `inventory_db`).
- 예전 문서/코드의 `orderlist_app`은 현재 기준 `orderlist_db`이다. 발견 시 수정 대상.
---
## 위험 명령 (사용자 확인 없이 실행 금지)
아래 명령은 **반드시 사용자에게 의도를 설명하고 명시적 승인을 받은 뒤** 실행한다.
| 분류 | 명령 예시 |
| --- | --- |
| 파일 삭제 | `rm -rf`, `Remove-Item -Recurse -Force` |
| DB 파괴 | `DROP DATABASE`, `DROP TABLE`, `DROP SCHEMA` |
| 데이터 삭제 | `TRUNCATE`, 조건 없는 대량 `DELETE`, `UPDATE` |
| Docker 파괴 | `docker volume rm`, `docker volume prune`, `docker system prune -a --volumes` |
| Git 파괴 | `git reset --hard`, `git push --force`, `git clean -fd`, `git branch -D` |
| 운영 초기화 | 운영 DB 덤프 덮어쓰기, 마이그레이션 롤백 |
원칙:
1. 실행 전 현재 상태 확인 명령을 먼저 보여준다 (예: `docker ps`, `\l`, `git status`).
2. 백업 존재 여부와 위치를 명시한다.
3. 실행 후 결과 확인 절차를 같이 제시한다.
---
## 서버 작업 원칙
- 배포, DB 복구, Docker 작업 전에는 현재 상태 확인 명령을 먼저 제안한다.
- PostgreSQL 작업 전에는 DB명, 컨테이너명, 포트, 백업 위치를 확인한다.
- 운영 서버 경로와 개발 PC 경로를 혼동하지 않는다.
- 개발 PC: `G:\내 드라이브\프로젝트\Main-app`
- **운영 서버 (main-app): `/opt/www/main`** ← 본 프로젝트 경로
- 참고용 (같은 호스트 내 다른 서비스 경로): `/opt/dbx-corm`, `/opt/dbx-orderlist`
- 명령 예시·문서 작성 시 main-app 경로는 **반드시 `/opt/www/main`** 사용. 상세는 `docs/DEPLOYMENT.md`.
---
## 환경 변수 / 비밀값
- `.env`, `.env.local`, `.env.production`**Git에 절대 커밋하지 않는다**.
- 예시 파일(`*.example`)만 커밋한다.
- 비밀값 유출이 의심되면 즉시 회전(rotate)을 권고한다.
- 신규 환경변수 추가 시 `*.example` 파일과 본 문서(또는 `docs/DEPLOYMENT.md`)에 변수 설명을 함께 갱신한다.
---
## 디렉터리 구조 (요약)
```
Main-app/
├─ app/ FastAPI 앱 소스
├─ docs/ 운영/설계 문서 (작업 전 필독)
├─ scripts/ 배포·유지보수 스크립트
├─ skills/ Claude Code 규칙/스킬
├─ docker-compose.yml 운영 컴포즈
├─ docker-compose.local.yml 로컬 컴포즈
├─ Dockerfile
├─ requirements.txt
└─ CLAUDE.md ← 이 문서
```
+357
View File
@@ -0,0 +1,357 @@
# Ui — Style Reference
> Monochromatic architectural blueprint precise, functional forms on a stark, bright canvas.
**Theme:** light
This design system feels like a finely tuned machine, presenting a clean and precise interface with a stark black-and-white aesthetic. The visual mood is serious and functional, achieved through a dominant achromatic palette and very subtle elevation. Geometric balance is created by mixing hard 10-14px radii for cards and inputs with highly rounded (near-pill) buttons and badges, suggesting both structure and approachability. The use of a custom sans-serif font across all elements with meticulous letter-spacing creates a unified, crisp typographic voice.
## Tokens — Colors
| Name | Value | Token | Role |
|------|-------|-------|------|
| Canvas White | `#ffffff` | `--color-canvas-white` | Page background, primary card surfaces, popovers. The foundational bright base. |
| Ghost Gray | `#f2f2f2` | `--color-ghost-gray` | Secondary background for segmented sections or subtle card differentiation. Lighter than default background. |
| Subtle Ash | `#e5e5e5` | `--color-subtle-ash` | Border colors for inputs, cards, and dividers. Provides definition without harshness. |
| Midtone Gray | `#737373` | `--color-midtone-gray` | Muted text, placeholder text in inputs, secondary icons. Recedes into the background. |
| Rich Black | `#0a0a0a` | `--color-rich-black` | Primary text color for body copy, standard icons, badges with white text. High contrast for readability. |
| Deep Black | `#000000` | `--color-deep-black` | Headings, active state button backgrounds, highlighted text. The darkest tone for strong emphasis. |
| Callout Red | `#c22b10` | `--color-callout-red` | Destructive actions, error states. A muted, serious red. |
| Success Green | `#10c22b` | `--color-success-green` | Success states, positive confirmations. A muted, serious green. |
## Tokens — Typography
### Geist — Primary brand font for all UI text, headings, and body. Its varied weights and precise tracking create a modern, technical feel. · `--font-geist`
- **Substitute:** Inter
- **Weights:** 400, 500, 600
- **Sizes:** 12px, 13px, 14px, 16px, 18px, 48px
- **Line height:** 1.00, 1.10, 1.20, 1.33, 1.38, 1.43, 1.50, 1.56, 1.63, 2.00
- **Letter spacing:** -0.0500em at 48px, -0.0250em at 18px
- **Role:** Primary brand font for all UI text, headings, and body. Its varied weights and precise tracking create a modern, technical feel.
### Geist Mono — Used for code snippets or specific input fields requiring monospaced characters. Reinforces a technical aesthetic. · `--font-geist-mono`
- **Substitute:** IBM Plex Mono
- **Weights:** 400
- **Sizes:** 14px
- **Line height:** 1.43
- **Letter spacing:** normal
- **Role:** Used for code snippets or specific input fields requiring monospaced characters. Reinforces a technical aesthetic.
### Type Scale
| Role | Size | Line Height | Letter Spacing | Token |
|------|------|-------------|----------------|-------|
| caption | 12px | 1.5 | — | `--text-caption` |
| body | 14px | 1.43 | — | `--text-body` |
| heading | 18px | 1.33 | -0.45px | `--text-heading` |
| display | 48px | 1 | -2.4px | `--text-display` |
## Tokens — Spacing & Shapes
**Density:** compact
### Spacing Scale
| Name | Value | Token |
|------|-------|-------|
| 4 | 4px | `--spacing-4` |
| 5 | 5px | `--spacing-5` |
| 6 | 6px | `--spacing-6` |
| 8 | 8px | `--spacing-8` |
| 10 | 10px | `--spacing-10` |
| 12 | 12px | `--spacing-12` |
| 16 | 16px | `--spacing-16` |
| 20 | 20px | `--spacing-20` |
| 24 | 24px | `--spacing-24` |
| 32 | 32px | `--spacing-32` |
| 40 | 40px | `--spacing-40` |
| 80 | 80px | `--spacing-80` |
| 83 | 83px | `--spacing-83` |
### Border Radius
| Element | Value |
|---------|-------|
| pill | 9999px |
| badge | 26px |
| cards | 14px |
| input | 10px |
| buttons | 10px |
| default | 10px |
### Shadows
| Name | Value | Token |
|------|-------|-------|
| subtle | `lab(100 0 0) 0px 0px 0px 2px` | `--shadow-subtle` |
| subtle-2 | `oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0...` | `--shadow-subtle-2` |
### Layout
- **Section gap:** 83px
- **Card padding:** 16px
- **Element gap:** 8px
## Components
### Primary Action Button
**Role:** Call to action.
Solid Deep Black (#000000) background with Canvas White (#ffffff) text. Features a 10px border-radius, 8px vertical padding, and 48px horizontal padding, making it a prominent rectangular element.
### Ghost Button
**Role:** Secondary or tertiary actions, often within groups.
Transparent background with Rich Black (#0a0a0a) text. Uses a 9999px border-radius for a pill shape, with no explicit padding defined by variants, implying content-based sizing.
### Split Button Left
**Role:** Left segment of a grouped button control.
Canvas White (#ffffff) background with Deep Black (#000000) text. Features a 10px border-radius on the left, 0px on the right, and 10px horizontal padding. Borders in Subtle Ash (#e5e5e5).
### Split Button Right
**Role:** Right segment of a grouped button control.
Canvas White (#ffffff) background with Deep Black (#000000) text. Features a 10px border-radius on the right, 0px on the left. Borders in Subtle Ash (#e5e5e5).
### Elevated Card
**Role:** Containers for distinct content blocks, forms, or data.
Canvas White (#ffffff) background with a 14px border-radius. Features a subtle shadow: oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px, providing minimal elevation. Inner content padding is 16px.
### Plain Input Field
**Role:** Standard text input.
Transparent background with Rich Black (#0a0a0a) text. Defined by a 1px Subtle Ash (#e5e5e5) border and a 10px border-radius. Inner padding is 4px vertical, 10px horizontal.
### Segmented Input Left
**Role:** Left segment of a grouped input control.
Transparent background with Rich Black (#0a0a0a) text. Features a 10px border-radius on the left and 0px on the right. Defined by a 1px Subtle Ash (#e5e5e5) border. Inner padding is 4px vertical, 10px horizontal.
### Inverse Tag Badge
**Role:** Highlighting status or category, with high contrast.
Deep Black (#171717) background with Canvas White (#ffffff) text. Features a 26px border-radius, creating a pill shape. Padding is 2px vertical, 8px horizontal.
### Neutral Tag Badge
**Role:** Subtle categorization or status.
Ghost Gray (#f2f2f2) background with Rich Black (#0a0a0a) text. Features a 26px border-radius, creating a pill shape. Padding is 2px vertical, 8px horizontal.
### Outline Tag Badge
**Role:** Very subtle categorization or option.
Transparent background with Rich Black (#0a0a0a) text. Features a 26px border-radius and a Light Ash (#a1a1a1) border. Padding is 2px vertical, 8px horizontal.
## Do's and Don'ts
### Do
- Use Deep Black (#000000) for primary headings and active states to command attention.
- Apply Subtle Ash (#e5e5e5) for all primary borders and dividers to maintain a subtle visual separation.
- Ensure input fields and cards consistently use a 10px or 14px border-radius, respectively, for geometric stability.
- Employ Geist font universally, leveraging its 400, 500, and 600 weights to establish clear hierarchy without introducing new typefaces.
- Maintain a default element gap of 8px, but use 16px for card inner padding to create adequate breathing room for content.
- Utilize 9999px or 26px border-radius for all interactive buttons and badges to create a soft, approachable pill shape.
### Don't
- Avoid using highly saturated colors; stick to the achromatic scale and the two semantic reds and greens.
- Do not introduce additional font families; the current choices are sufficient for all typographic needs.
- Refrain from using strong, multi-directional shadows; rely on minimal 1px shadows or simple borders for elevation.
- Do not deviate from the established border-radius values; the mix of sharp 0px (in split elements), 10px, 14px, and 9999px is intentional.
- Don't add excessive padding or margin; the design favors a compact density with specific, calculated spacing.
- Avoid decorative gradients; the brand's aesthetic is built on flat colors and subtle depth.
## Surfaces
| Level | Name | Value | Purpose |
|-------|------|-------|---------|
| 0 | Canvas White | `#ffffff` | Primary page background and base surface for most content. |
| 1 | Elevated Card | `#ffffff` | Content cards and distinct sections that require a subtle lift, defined by borders or minimal shadow. |
| 2 | Search/Input Field | `#ffffff` | Interactive elements like search bars and inputs, often bordered. |
| 3 | Popovers/Overlays | `#ffffff` | Transient UI elements that appear above other content. |
| 4 | Ghost Gray Background | `#f2f2f2` | Used as a background color for secondary buttons or badges, indicating a slightly lower hierarchy. |
## Elevation
- **Elevated Card:** `oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px`
- **Focus Ring:** `lab(100 0 0) 0px 0px 0px 2px`
## Imagery
The visual language is purely utilitarian and functional. No photography or complex illustrations are present. Icons are monochromatic, typically black stroke or fill on white backgrounds, aligning with the stark aesthetic. Product components are presented directly, with an emphasis on UI elements rather than lifestyle or marketing visuals. Imagery's role is explanatory (via icons) or for showcasing UI components, maintaining a text-dominant layout. There are no decorative visuals.
## Layout
The page maintains a centered, contained layout with a maximum visible width, creating a focused content area. The hero section features a prominent, centered headline and subtext over the Canvas White background, followed by centrally aligned CTA buttons. Sections below are arranged in a multi-column grid, showcasing various UI components (forms, cards, controls). The rhythm is consistent vertical spacing, creating an organized, information-dense display. Navigation is a sticky top-bar with compact links and utility actions.
## Agent Prompt Guide
### Quick Color Reference
- **Text Primary:** #0a0a0a
- **Text Muted:** #737373
- **Background:** #ffffff
- **CTA Background:** #000000
- **Border:** #e5e5e5
- **Accent (Semantic Red):** #c22b10
### Example Component Prompts
1. **Create a Hero Section:** Canvas White (#ffffff) background. Headline 'The Foundation for your Design System' in Geist weight 600, 48px, line-height 1.0, letter-spacing -2.4px, color Deep Black (#000000). Subtext 'A set of beautifully designed components that you can customize...' in Geist weight 400, 18px, line-height 1.33, letter-spacing -0.45px, color Rich Black (#0a0a0a). Below this, a Primary Action Button labeled 'New Project' and a Ghost Button labeled 'View Components'. Section gap 83px.
2. **Generate an Elevated Card:** Canvas White (#ffffff) background, 14px border-radius, with shadow 'oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px'. Inside, use 16px internal padding. Title 'Payment Method' in Geist weight 500, 16px, Rich Black (#0a0a0a). Body text 'All transactions are secure...' in Geist weight 400, 14px, Midtone Gray (#737373). Include a Plain Input Field with label 'Name on Card'.
3. **Design a Form Input Group:** Two segmented input fields. The first is a Segmented Input Left for 'Card Number', with placeholder '1234 5678 9012 3456'. The second is a Plain Input Field for 'CVV', placeholder '123'. Both bordered with Subtle Ash (#e5e5e5).
4. **Create a Navigation Bar:** Canvas White (#ffffff) background. Left aligned: 'Docs', 'Components', 'Blocks', 'Charts', 'Directory', 'Create' as text links in Geist weight 400, 14px, Rich Black (#0a0a0a). Right aligned: a Plain Input Field 'Search documentation...' and a Primary Action Button labeled '+ New'. Elements within the nav should have 8px element gap between them.
## Similar Brands
- **Vercel** — Dominant use of a stark black-and-white achromatic palette, clean typography, and focus on developer tools and component showcasing.
- **Linear** — Systematic grid-based UI, minimal use of color, and high-fidelity, component-driven interaction patterns.
- **Figma** — Functional, dark-mode leaning interfaces with strong typography and precise spacing, emphasizing tool-like utility.
- **Revolut (early UI)** — Modern, crisp UI with strong geometric shapes, restrained use of color for status, and emphasis on clear data presentation.
## Quick Start
### CSS Custom Properties
```css
:root {
/* Colors */
--color-canvas-white: #ffffff;
--color-ghost-gray: #f2f2f2;
--color-subtle-ash: #e5e5e5;
--color-midtone-gray: #737373;
--color-rich-black: #0a0a0a;
--color-deep-black: #000000;
--color-callout-red: #c22b10;
--color-success-green: #10c22b;
/* Typography — Font Families */
--font-geist: 'Geist', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-geist-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
/* Typography — Scale */
--text-caption: 12px;
--leading-caption: 1.5;
--text-body: 14px;
--leading-body: 1.43;
--text-heading: 18px;
--leading-heading: 1.33;
--tracking-heading: -0.45px;
--text-display: 48px;
--leading-display: 1;
--tracking-display: -2.4px;
/* Typography — Weights */
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
/* Spacing */
--spacing-4: 4px;
--spacing-5: 5px;
--spacing-6: 6px;
--spacing-8: 8px;
--spacing-10: 10px;
--spacing-12: 12px;
--spacing-16: 16px;
--spacing-20: 20px;
--spacing-24: 24px;
--spacing-32: 32px;
--spacing-40: 40px;
--spacing-80: 80px;
--spacing-83: 83px;
/* Layout */
--section-gap: 83px;
--card-padding: 16px;
--element-gap: 8px;
/* Border Radius */
--radius-md: 4px;
--radius-lg: 10px;
--radius-xl: 14px;
--radius-3xl: 26px;
--radius-full: 9996px;
--radius-full-2: 9999px;
--radius-full-3: 159981px;
--radius-full-4: 159984px;
/* Named Radii */
--radius-pill: 9999px;
--radius-badge: 26px;
--radius-cards: 14px;
--radius-input: 10px;
--radius-buttons: 10px;
--radius-default: 10px;
/* Shadows */
--shadow-subtle: lab(100 0 0) 0px 0px 0px 2px;
--shadow-subtle-2: oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px;
/* Surfaces */
--surface-canvas-white: #ffffff;
--surface-elevated-card: #ffffff;
--surface-searchinput-field: #ffffff;
--surface-popoversoverlays: #ffffff;
--surface-ghost-gray-background: #f2f2f2;
}
```
### Tailwind v4
```css
@theme {
/* Colors */
--color-canvas-white: #ffffff;
--color-ghost-gray: #f2f2f2;
--color-subtle-ash: #e5e5e5;
--color-midtone-gray: #737373;
--color-rich-black: #0a0a0a;
--color-deep-black: #000000;
--color-callout-red: #c22b10;
--color-success-green: #10c22b;
/* Typography */
--font-geist: 'Geist', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-geist-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
/* Typography — Scale */
--text-caption: 12px;
--leading-caption: 1.5;
--text-body: 14px;
--leading-body: 1.43;
--text-heading: 18px;
--leading-heading: 1.33;
--tracking-heading: -0.45px;
--text-display: 48px;
--leading-display: 1;
--tracking-display: -2.4px;
/* Spacing */
--spacing-4: 4px;
--spacing-5: 5px;
--spacing-6: 6px;
--spacing-8: 8px;
--spacing-10: 10px;
--spacing-12: 12px;
--spacing-16: 16px;
--spacing-20: 20px;
--spacing-24: 24px;
--spacing-32: 32px;
--spacing-40: 40px;
--spacing-80: 80px;
--spacing-83: 83px;
/* Border Radius */
--radius-md: 4px;
--radius-lg: 10px;
--radius-xl: 14px;
--radius-3xl: 26px;
--radius-full: 9996px;
--radius-full-2: 9999px;
--radius-full-3: 159981px;
--radius-full-4: 159984px;
/* Shadows */
--shadow-subtle: lab(100 0 0) 0px 0px 0px 2px;
--shadow-subtle-2: oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px;
}
```
+190 -7
View File
@@ -9,10 +9,18 @@ from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
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.cupang import build_cupang_store, build_itemcode_reader
from .modules.cupang import router as cupang_router
from .modules.expense import CategoryStore, build_expense_store
from .modules.expense import router as expense_router
from .modules.vacation import build_vacation_store
from .modules.vacation import router as vacation_router
from .store import ( from .store import (
APPROVER_KEYS,
MODULE_KEYS, MODULE_KEYS,
SUPER_ADMIN_EMAIL, SUPER_ADMIN_EMAIL,
UserStore, UserStore,
@@ -21,6 +29,17 @@ from .store import (
is_admin, is_admin,
) )
# 권한 키 한글 라벨 (admin.html / 사이드바 공용)
MODULE_LABELS: dict[str, str] = {
"corm": "CORM",
"order": "Order",
"expense": "개인경비",
"vacation": "휴가",
"cupang": "쿠팡 밀크런",
"expense_approver": "개인경비",
"vacation_approver": "휴가",
}
# OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름. # OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름.
SESSION_COOKIE_DEFAULT = "session" SESSION_COOKIE_DEFAULT = "session"
@@ -83,10 +102,45 @@ app.add_middleware(
) )
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
# 모듈별 templates 디렉토리를 추가로 검색하도록 ChoiceLoader 설정.
# 신규 모듈 추가 시 아래 리스트에 `BASE_DIR / "modules" / "<name>" / "templates"` 만 추가.
_MODULE_TEMPLATE_DIRS = [
BASE_DIR / "modules" / "expense" / "templates",
BASE_DIR / "modules" / "cupang" / "templates",
BASE_DIR / "modules" / "vacation" / "templates",
]
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
templates.env.loader = ChoiceLoader(
[
FileSystemLoader(str(BASE_DIR / "templates")),
*[FileSystemLoader(str(p)) for p in _MODULE_TEMPLATE_DIRS if p.exists()],
]
)
oauth = build_google_oauth() oauth = build_google_oauth()
user_store = UserStore(DATA_DIR / "users.json") user_store = UserStore(DATA_DIR / "users.json")
# 모듈별 데이터 저장소.
# EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일.
app.state.data_dir = DATA_DIR # 모듈에서 첨부 저장 경로 등으로 참조
app.state.expense_store = build_expense_store(
dsn=env("EXPENSE_DB_URL") or None,
json_path=DATA_DIR / "expense.json",
)
# 분류(category) 설정 — 관리자가 추가/삭제, 저장 즉시 반영. 항상 JSON 파일.
app.state.expense_category_store = CategoryStore(DATA_DIR / "expense_categories.json")
# 쿠팡 밀크런: CUPANG_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
# 상품 검색은 itemcode_db 읽기 전용(미설정 시 수동 입력 폴백).
app.state.cupang_store = build_cupang_store(dsn=env("CUPANG_DB_URL") or None)
app.state.itemcode_reader = build_itemcode_reader()
# 휴가 관리: VACATION_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
app.state.vacation_store = build_vacation_store(dsn=env("VACATION_DB_URL") or None)
# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄.
app.include_router(expense_router)
app.include_router(cupang_router)
app.include_router(vacation_router)
def public_url_for(request: Request, route_name: str) -> str: def public_url_for(request: Request, route_name: str) -> str:
public_base_url = env("PUBLIC_BASE_URL").rstrip("/") public_base_url = env("PUBLIC_BASE_URL").rstrip("/")
@@ -203,19 +257,29 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
"title": "개인경비", "title": "개인경비",
"subtitle": "Personal Expense", "subtitle": "Personal Expense",
"description": "법인카드/개인경비 사용 내역을 등록·증빙하고 정산을 신청합니다.", "description": "법인카드/개인경비 사용 내역을 등록·증빙하고 정산을 신청합니다.",
"url": "#", "url": "/expense/",
"health_url": None, "health_url": "/expense/health",
"status": "preparing", "status": "ready",
"category": "관리", "category": "관리",
}, },
{
"key": "cupang",
"title": "쿠팡 밀크런",
"subtitle": "Coupang Milk-run",
"description": "쿠팡 밀크런 출고 일정·박스 계산·입고센터를 달력에서 관리합니다.",
"url": "/cupang/",
"health_url": "/cupang/health",
"status": "ready",
"category": "운영",
},
{ {
"key": "vacation", "key": "vacation",
"title": "휴가", "title": "휴가",
"subtitle": "Vacation", "subtitle": "Vacation",
"description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 확인합니다.", "description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 달력에서 관리합니다.",
"url": "#", "url": "/vacation/",
"health_url": None, "health_url": "/vacation/health",
"status": "preparing", "status": "ready",
"category": "관리", "category": "관리",
}, },
] ]
@@ -225,6 +289,66 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
return items return items
def _icon_svg(name: str) -> str:
"""좌측 사이드바용 인라인 아이콘. 외부 의존 없는 작은 SVG."""
paths = {
"home": '<path d="M3 11 12 3l9 8"/><path d="M5 10v10h14V10"/>',
"expense": '<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><path d="M7 15h4"/>',
"vacation": '<path d="M8 2v4"/><path d="M16 2v4"/><rect x="3" y="6" width="18" height="15" rx="2"/><path d="M3 11h18"/>',
"corm": '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z"/>',
"order": '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>',
"cupang": '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/>',
"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"])
return (
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
'stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
f"{body}</svg>"
)
def build_erp_nav(
user_rec: dict[str, Any], active: str | None = None
) -> list[dict[str, Any]]:
"""좌측 사이드바 메뉴. 모듈 정의(_menu_items_for)와 동기화한다.
각 항목: key, label, group, url, target, icon, active, disabled, disabled_reason.
"""
items: list[dict[str, Any]] = [
{
"key": "home",
"label": "",
"group": "ERP",
"url": "/",
"target": "_self",
"icon": _icon_svg("home"),
},
]
for m in _menu_items_for(user_rec):
if not m["allowed"]:
continue
target = "_blank" if m["url"].startswith("http") else "_self"
items.append(
{
"key": m["key"],
"label": m["title"],
"group": m["category"],
"url": m["url"] if m["status"] == "ready" else "#",
"target": target,
"icon": _icon_svg(m["key"]),
"disabled": m["status"] != "ready",
"disabled_reason": "준비중" if m["status"] != "ready" else None,
}
)
# 같은 그룹끼리 묶이도록 정렬(그룹 헤더 중복 방지). 그룹 내 순서는 유지(stable).
group_order = {"ERP": 0, "운영": 1, "관리": 2}
items.sort(key=lambda it: group_order.get(it["group"], 9))
for it in items:
it["active"] = it["key"] == active
return items
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def home(request: Request) -> HTMLResponse: async def home(request: Request) -> HTMLResponse:
sess = get_session_user(request) sess = get_session_user(request)
@@ -235,6 +359,10 @@ async def home(request: Request) -> HTMLResponse:
# 도메인은 통과했으나 저장소에 없음 — 세션 정리 후 재로그인 # 도메인은 통과했으나 저장소에 없음 — 세션 정리 후 재로그인
request.session.clear() request.session.clear()
return render_template(request, "login.html") return render_template(request, "login.html")
# 슈퍼 관리자 → 업무 모듈 선택 화면(main.html).
# 일반 사용자 → ERP 메인(좌측 메뉴 + 우측 콘텐츠).
if user_rec.get("is_super_admin"):
menu_items = _menu_items_for(user_rec) menu_items = _menu_items_for(user_rec)
return render_template( return render_template(
request, request,
@@ -246,6 +374,35 @@ async def home(request: Request) -> HTMLResponse:
}, },
) )
return render_template(
request,
"erp_home.html",
{
"user": user_rec,
"is_admin": is_admin(user_rec),
"nav_items": build_erp_nav(user_rec, active="home"),
"page_title": "ERP 홈",
"page_subtitle": "오늘의 업무를 시작하세요.",
},
)
@app.get("/modules", response_class=HTMLResponse)
async def modules_page(request: Request) -> HTMLResponse:
"""업무 모듈 선택 화면. 슈퍼 관리자가 사이드바에서 다시 진입할 때 사용."""
user_rec = get_current_user_record(request)
if user_rec is None:
return RedirectResponse(url="/login", status_code=303)
return render_template(
request,
"main.html",
{
"user": user_rec,
"menu_items": _menu_items_for(user_rec),
"is_admin": is_admin(user_rec),
},
)
@app.get("/login") @app.get("/login")
async def login(request: Request): async def login(request: Request):
@@ -345,6 +502,8 @@ async def admin_page(request: Request) -> HTMLResponse:
"user": rec, "user": rec,
"users": users, "users": users,
"module_keys": list(MODULE_KEYS), "module_keys": list(MODULE_KEYS),
"module_labels": MODULE_LABELS,
"approver_keys": list(APPROVER_KEYS),
"super_admin_email": SUPER_ADMIN_EMAIL, "super_admin_email": SUPER_ADMIN_EMAIL,
"is_admin": True, "is_admin": True,
}, },
@@ -357,11 +516,35 @@ class UpdatePermissionsBody(BaseModel):
modules: dict[str, bool] | None = None modules: dict[str, bool] | None = None
class CreateUserBody(BaseModel):
email: str
name: str = ""
role: str = "user"
modules: dict[str, bool] | None = None
@app.get("/api/users") @app.get("/api/users")
async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse: async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse:
return JSONResponse({"users": user_store.list_all()}) return JSONResponse({"users": user_store.list_all()})
@app.post("/api/users")
async def api_create_user(
body: CreateUserBody,
_: dict[str, Any] = Depends(require_admin),
) -> JSONResponse:
try:
rec = user_store.create_user(
email=body.email,
name=body.name,
role=body.role,
modules=body.modules,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"user": rec}, status_code=201)
@app.put("/api/users/{email}") @app.put("/api/users/{email}")
async def api_update_user( async def api_update_user(
email: str, email: str,
View File
+45
View File
@@ -0,0 +1,45 @@
"""쿠팡 밀크런(cupang) 모듈.
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
- 라우터: `router.py` (FastAPI APIRouter, prefix=/cupang)
- 저장소: `db.py` (cupang_db / PostgreSQL 전용) + `store.py` (상수/계산)
- 상품검색: `itemcode.py` (itemcode_db 읽기 전용)
- 템플릿: `templates/cupang/`
데이터 저장은 cupang_db 전용이다. CUPANG_DB_URL 미설정 시 build_cupang_store 는
None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를 보여준다(앱은 죽지 않음).
"""
from typing import Any
from .router import router
from .store import DEFAULT_CENTERS, SHIP_METHODS, STATUSES, compute_boxes
__all__ = [
"router",
"STATUSES",
"SHIP_METHODS",
"DEFAULT_CENTERS",
"compute_boxes",
"build_cupang_store",
"build_itemcode_reader",
]
def build_cupang_store(*, dsn: str | None) -> Any:
"""CUPANG_DB_URL 이 있으면 CupangDBStore, 없으면 None.
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시.
"""
if not dsn:
return None
from .db import CupangDBStore # 지연 import (개발 환경 deps 없을 수 있음)
return CupangDBStore(dsn)
def build_itemcode_reader() -> Any:
"""itemcode_db 읽기 전용 상품 검색 리더. 설정 없으면 비활성(enabled=False)."""
from .itemcode import ItemcodeReader # 지연 import
return ItemcodeReader()
+626
View File
@@ -0,0 +1,626 @@
"""cupang_db PostgreSQL 저장소.
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — expense 모듈과 동일 패턴.
- 연결 정보: 환경변수 `CUPANG_DB_URL`
(예: postgresql://cupang_app:<pwd>@postgres-db:5432/cupang_db)
- 스키마(테이블/인덱스/트리거/seed)는 앱이 만들지 않는다.
`scripts/sql/cupang_db_init.sql` 을 superuser 가 사전 적용한다.
앱 계정(cupang_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다.
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
박스 수 계산은 서버에서 `store.compute_boxes` 로 재계산하여 저장한다.
클라이언트가 보낸 박스 수는 신뢰하지 않는다.
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from typing import Any
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from app.timezone import KST
from .store import STATUSES, compute_boxes
class CupangDBStore:
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()
# ════════════════════════════════════════════════════════════
# 입고센터 (cupang_centers)
# ════════════════════════════════════════════════════════════
def list_centers(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
where = "" if include_inactive else "WHERE active = TRUE"
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM cupang_centers {where} "
"ORDER BY active DESC, sort_order ASC, name ASC"
).fetchall()
return [self._center_serialize(r) for r in rows]
def get_center(self, *, center_id: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM cupang_centers WHERE id = %s", (center_id,)
).fetchone()
return self._center_serialize(row) if row else None
def create_center(self, *, name: str, sort_order: int = 0) -> dict[str, Any]:
name = (name or "").strip()
if not name:
raise ValueError("센터명 필수")
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO cupang_centers (name, sort_order)
VALUES (%s, %s)
ON CONFLICT (name) DO UPDATE
SET active = TRUE, sort_order = EXCLUDED.sort_order
RETURNING *
""",
(name, sort_order),
).fetchone()
return self._center_serialize(row)
def update_center(
self,
*,
center_id: int,
name: str | None = None,
active: bool | None = None,
sort_order: int | None = None,
) -> dict[str, Any]:
sets: list[str] = []
params: list[Any] = []
if name is not None:
n = name.strip()
if not n:
raise ValueError("센터명은 비울 수 없습니다.")
sets.append("name = %s")
params.append(n)
if active is not None:
sets.append("active = %s")
params.append(bool(active))
if sort_order is not None:
sets.append("sort_order = %s")
params.append(int(sort_order))
if not sets:
raise ValueError("변경할 값이 없습니다.")
params.append(center_id)
with self._pool.connection() as conn:
row = conn.execute(
f"UPDATE cupang_centers SET {', '.join(sets)} "
"WHERE id = %s RETURNING *",
params,
).fetchone()
if not row:
raise KeyError(center_id)
return self._center_serialize(row)
def center_in_use(self, *, center_id: int) -> bool:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT 1 FROM cupang_shipments WHERE center_id = %s LIMIT 1",
(center_id,),
).fetchone()
return row is not None
def delete_center(self, *, center_id: int) -> dict[str, Any]:
"""사용 중이면 hard delete 하지 않고 active=false 로 비활성화한다.
반환: {"deleted": bool, "deactivated": bool}
"""
if self.center_in_use(center_id=center_id):
self.update_center(center_id=center_id, active=False)
return {"deleted": False, "deactivated": True}
with self._pool.connection() as conn:
cur = conn.execute(
"DELETE FROM cupang_centers WHERE id = %s", (center_id,)
)
if cur.rowcount == 0:
raise KeyError(center_id)
return {"deleted": True, "deactivated": False}
# ════════════════════════════════════════════════════════════
# 박스 입수량 규칙 (cupang_box_rules)
# ════════════════════════════════════════════════════════════
def list_box_rules(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
where = "" if include_inactive else "WHERE active = TRUE"
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM cupang_box_rules {where} "
"ORDER BY product_code ASC"
).fetchall()
return [self._rule_serialize(r) for r in rows]
def upsert_box_rule(
self,
*,
product_code: str,
units_per_box: int,
product_name_snapshot: str = "",
box_name: str = "쿠팡박스",
memo: str = "",
) -> dict[str, Any]:
code = (product_code or "").strip()
if not code:
raise ValueError("제품코드 필수")
upb = int(units_per_box)
if upb <= 0:
raise ValueError("박스당 입수량은 1 이상이어야 합니다.")
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO cupang_box_rules
(product_code, product_name_snapshot, box_name, units_per_box, memo)
VALUES (%s, %s, %s, %s, %s)
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
RETURNING *
""",
(code, product_name_snapshot.strip(), (box_name or "쿠팡박스").strip(), upb, memo.strip()),
).fetchone()
return self._rule_serialize(row)
def delete_box_rule(self, *, rule_id: int) -> None:
"""완전 삭제(hard). 라인의 box_rule_id 는 ON DELETE 미설정이므로
참조 중이면 FK 위반 가능 → 참조 라인의 box_rule_id 를 먼저 NULL 처리."""
with self._pool.connection() as conn:
with conn.transaction():
conn.execute(
"UPDATE cupang_shipment_lines SET box_rule_id = NULL WHERE box_rule_id = %s",
(rule_id,),
)
cur = conn.execute(
"DELETE FROM cupang_box_rules WHERE id = %s", (rule_id,)
)
if cur.rowcount == 0:
raise KeyError(rule_id)
# ════════════════════════════════════════════════════════════
# 제품명 카탈로그 (cupang_products)
# itemcode_db 에서 가져와 등록한 제품 목록. 폼의 제품명 드롭다운 소스.
# 제품명 선택 → product_code 자동 채움.
# ════════════════════════════════════════════════════════════
def list_products(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
where = "" if include_inactive else "WHERE active = TRUE"
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM cupang_products {where} "
"ORDER BY product_code ASC"
).fetchall()
return [self._product_serialize(r) for r in rows]
def upsert_product(
self, *, product_code: str, product_name: str, sort_order: int = 0
) -> dict[str, Any]:
code = (product_code or "").strip()
name = (product_name or "").strip()
if not code or not name:
raise ValueError("제품코드와 제품명 모두 필요합니다.")
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO cupang_products (product_code, product_name, sort_order)
VALUES (%s, %s, %s)
ON CONFLICT (product_code) DO UPDATE
SET product_name = EXCLUDED.product_name,
sort_order = EXCLUDED.sort_order,
active = TRUE
RETURNING *
""",
(code, name, sort_order),
).fetchone()
return self._product_serialize(row)
def set_product_active(self, *, product_id: int, active: bool) -> None:
with self._pool.connection() as conn:
cur = conn.execute(
"UPDATE cupang_products SET active = %s WHERE id = %s",
(bool(active), product_id),
)
if cur.rowcount == 0:
raise KeyError(product_id)
def delete_product(self, *, product_id: int) -> None:
with self._pool.connection() as conn:
cur = conn.execute(
"DELETE FROM cupang_products WHERE id = %s", (product_id,)
)
if cur.rowcount == 0:
raise KeyError(product_id)
# ════════════════════════════════════════════════════════════
# 출고 묶음 (cupang_shipments + cupang_shipment_lines)
# ════════════════════════════════════════════════════════════
def list_shipments(
self,
*,
year: int | None = None,
month: int | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> list[dict[str, Any]]:
"""헤더 목록(라인 제외). 달력/리스트 표시에 사용.
year+month 가 주어지면 작성일/출고일/센터입고일 중 하나라도 해당 월에
걸치는 묶음을 모두 포함한다(달력 표시용).
"""
clauses: list[str] = []
params: list[Any] = []
if year and month:
clauses.append(
"(date_trunc('month', document_date) = make_date(%s, %s, 1)"
" OR date_trunc('month', ship_date) = make_date(%s, %s, 1)"
" OR date_trunc('month', center_arrival_date) = make_date(%s, %s, 1))"
)
params.extend([year, month, year, month, year, month])
if date_from:
clauses.append("ship_date >= %s")
params.append(date_from)
if date_to:
clauses.append("ship_date <= %s")
params.append(date_to)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM cupang_shipments {where} "
"ORDER BY ship_date ASC, id ASC",
params,
).fetchall()
return [self._shipment_serialize(r) for r in rows]
def get_shipment(self, *, shipment_id: int, with_lines: bool = True) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM cupang_shipments WHERE id = %s", (shipment_id,)
).fetchone()
if not row:
return None
head = self._shipment_serialize(row)
if with_lines:
line_rows = conn.execute(
"SELECT * FROM cupang_shipment_lines WHERE shipment_id = %s "
"ORDER BY line_no ASC",
(shipment_id,),
).fetchall()
head["lines"] = [self._line_serialize(r) for r in line_rows]
return head
def create_shipment(
self, *, created_by: str, header: dict[str, Any], lines: list[dict[str, Any]]
) -> dict[str, Any]:
h = self._normalize_header(header)
norm_lines = self._normalize_lines(lines)
if not norm_lines:
raise ValueError("품목 라인이 최소 1개 필요합니다.")
with self._pool.connection() as conn:
with conn.transaction():
row = conn.execute(
"""
INSERT INTO cupang_shipments
(created_by, document_date, ship_date,
center_arrival_date, center_id, center_name_snapshot,
ship_method, outbound_summary, worker, status, memo)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
created_by.lower().strip(),
h["document_date"],
h["ship_date"],
h["center_arrival_date"],
h["center_id"],
h["center_name_snapshot"],
h["ship_method"],
h["outbound_summary"],
h["worker"],
h["status"],
h["memo"],
),
).fetchone()
shipment_id = row["id"]
self._insert_lines(conn, shipment_id, norm_lines)
return self.get_shipment(shipment_id=shipment_id)
def update_shipment(
self, *, shipment_id: int, header: dict[str, Any], lines: list[dict[str, Any]]
) -> dict[str, Any]:
h = self._normalize_header(header)
norm_lines = self._normalize_lines(lines)
if not norm_lines:
raise ValueError("품목 라인이 최소 1개 필요합니다.")
with self._pool.connection() as conn:
with conn.transaction():
row = conn.execute(
"""
UPDATE cupang_shipments
SET document_date = %s, ship_date = %s,
center_arrival_date = %s, center_id = %s,
center_name_snapshot = %s, ship_method = %s,
outbound_summary = %s, worker = %s, memo = %s
WHERE id = %s
RETURNING *
""",
(
h["document_date"],
h["ship_date"],
h["center_arrival_date"],
h["center_id"],
h["center_name_snapshot"],
h["ship_method"],
h["outbound_summary"],
h["worker"],
h["memo"],
shipment_id,
),
).fetchone()
if not row:
raise KeyError(shipment_id)
# 라인 전체 교체 (CASCADE 아님 — 명시적 삭제 후 재삽입)
conn.execute(
"DELETE FROM cupang_shipment_lines WHERE shipment_id = %s",
(shipment_id,),
)
self._insert_lines(conn, shipment_id, norm_lines)
return self.get_shipment(shipment_id=shipment_id)
def set_status(self, *, shipment_id: int, status: str) -> dict[str, Any]:
if status not in STATUSES:
raise ValueError(f"허용되지 않는 상태: {status}")
with self._pool.connection() as conn:
row = conn.execute(
"UPDATE cupang_shipments SET status = %s WHERE id = %s RETURNING *",
(status, shipment_id),
).fetchone()
if not row:
raise KeyError(shipment_id)
return self._shipment_serialize(row)
def soft_delete(self, *, shipment_id: int) -> dict[str, Any]:
"""운영 안전을 위한 기본 삭제 — status='취소'."""
return self.set_status(shipment_id=shipment_id, status="취소")
def hard_delete(self, *, shipment_id: int) -> None:
"""완전 삭제(라인은 ON DELETE CASCADE). 취소 처리로 충분하므로 기본 미사용."""
with self._pool.connection() as conn:
cur = conn.execute(
"DELETE FROM cupang_shipments WHERE id = %s", (shipment_id,)
)
if cur.rowcount == 0:
raise KeyError(shipment_id)
def _insert_lines(self, conn: Any, shipment_id: int, lines: list[dict[str, Any]]) -> None:
for idx, ln in enumerate(lines, start=1):
calc = compute_boxes(ln["quantity"], ln.get("units_per_box"))
conn.execute(
"""
INSERT INTO cupang_shipment_lines
(shipment_id, line_no, product_code, product_name_snapshot,
quantity, box_rule_id, units_per_box, calculated_boxes,
remainder_units, manual_box_text, memo)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
shipment_id,
idx,
ln["product_code"],
ln["product_name_snapshot"],
ln["quantity"],
ln.get("box_rule_id"),
calc["units_per_box"],
calc["required_boxes"],
calc["remainder_units"],
ln.get("manual_box_text", ""),
ln.get("memo", ""),
),
)
# ════════════════════════════════════════════════════════════
# 달력 집계
# ════════════════════════════════════════════════════════════
def calendar_counts(self, *, year: int, month: int) -> dict[str, dict[str, int]]:
"""해당 월의 날짜별 작성/출고/입고 건수.
반환: { "YYYY-MM-DD": {"document": n, "ship": n, "arrival": n} }
취소 상태는 제외한다.
"""
first = (year, month)
out: dict[str, dict[str, int]] = {}
def _accumulate(rows: list[dict[str, Any]], key: str) -> None:
for r in rows:
d = r["d"]
ds = d.isoformat() if isinstance(d, date) else str(d)
out.setdefault(ds, {"document": 0, "ship": 0, "arrival": 0})
out[ds][key] = int(r["c"])
with self._pool.connection() as conn:
doc = conn.execute(
"SELECT document_date AS d, COUNT(*) AS c FROM cupang_shipments "
"WHERE status <> '취소' AND date_trunc('month', document_date) = make_date(%s,%s,1) "
"GROUP BY 1",
first,
).fetchall()
ship = conn.execute(
"SELECT ship_date AS d, COUNT(*) AS c FROM cupang_shipments "
"WHERE status <> '취소' AND date_trunc('month', ship_date) = make_date(%s,%s,1) "
"GROUP BY 1",
first,
).fetchall()
arr = conn.execute(
"SELECT center_arrival_date AS d, COUNT(*) AS c FROM cupang_shipments "
"WHERE status <> '취소' AND date_trunc('month', center_arrival_date) = make_date(%s,%s,1) "
"GROUP BY 1",
first,
).fetchall()
_accumulate(doc, "document")
_accumulate(ship, "ship")
_accumulate(arr, "arrival")
return out
# ════════════════════════════════════════════════════════════
# 정규화 / 직렬화 helpers
# ════════════════════════════════════════════════════════════
@staticmethod
def _normalize_header(header: dict[str, Any]) -> dict[str, Any]:
def _d(key: str) -> str:
v = str(header.get(key) or "").strip()
return v
document_date = _d("document_date")
ship_date = _d("ship_date")
center_arrival_date = _d("center_arrival_date")
if not document_date or not ship_date or not center_arrival_date:
raise ValueError("작성일/출고일/센터입고일은 필수입니다.")
status = str(header.get("status") or "작성중").strip()
if status not in STATUSES:
status = "작성중"
center_id_raw = header.get("center_id")
try:
center_id = int(center_id_raw) if center_id_raw not in (None, "", "0") else None
except (TypeError, ValueError):
center_id = None
return {
"document_date": document_date,
"ship_date": ship_date,
"center_arrival_date": center_arrival_date,
"center_id": center_id,
"center_name_snapshot": str(header.get("center_name_snapshot") or "").strip(),
"ship_method": str(header.get("ship_method") or "택배").strip() or "택배",
"outbound_summary": str(header.get("outbound_summary") or "").strip(),
"worker": str(header.get("worker") or "").strip(),
"status": status,
"memo": str(header.get("memo") or "").strip(),
}
@staticmethod
def _normalize_lines(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for raw in lines or []:
code = str(raw.get("product_code") or "").strip()
name = str(raw.get("product_name_snapshot") or raw.get("product_name") or "").strip()
try:
qty = int(raw.get("quantity") or 0)
except (TypeError, ValueError):
qty = 0
if not code or qty <= 0:
continue # 빈 라인 스킵
upb_raw = raw.get("units_per_box")
try:
upb = int(upb_raw) if upb_raw not in (None, "", "0") else None
except (TypeError, ValueError):
upb = None
rule_id_raw = raw.get("box_rule_id")
try:
rule_id = int(rule_id_raw) if rule_id_raw not in (None, "") else None
except (TypeError, ValueError):
rule_id = None
out.append(
{
"product_code": code,
"product_name_snapshot": name or code,
"quantity": qty,
"units_per_box": upb,
"box_rule_id": rule_id,
"manual_box_text": str(raw.get("manual_box_text") or "").strip(),
"memo": str(raw.get("memo") or "").strip(),
}
)
return out
@staticmethod
def _center_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
out["id"] = int(out["id"])
out["active"] = bool(out.get("active", True))
out["sort_order"] = int(out.get("sort_order", 0))
for k in ("created_at", "updated_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
return out
@staticmethod
def _rule_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
out["id"] = int(out["id"])
out["units_per_box"] = int(out.get("units_per_box", 0))
out["active"] = bool(out.get("active", True))
for k in ("created_at", "updated_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
return out
@staticmethod
def _product_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
out["id"] = int(out["id"])
out["active"] = bool(out.get("active", True))
out["sort_order"] = int(out.get("sort_order", 0))
for k in ("created_at", "updated_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
return out
@staticmethod
def _shipment_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
out["id"] = int(out["id"])
out["center_id"] = int(out["center_id"]) if out.get("center_id") is not None else None
for k in ("document_date", "ship_date", "center_arrival_date"):
v = out.get(k)
if isinstance(v, date):
out[k] = v.isoformat()
for k in ("created_at", "updated_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
return out
@staticmethod
def _line_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
out["id"] = int(out["id"])
out["line_no"] = int(out.get("line_no", 0))
out["quantity"] = int(out.get("quantity", 0))
for k in ("box_rule_id", "units_per_box", "calculated_boxes", "remainder_units"):
v = out.get(k)
out[k] = int(v) if v is not None else None
for k in ("created_at", "updated_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
return out
+62
View File
@@ -0,0 +1,62 @@
"""대한민국 공휴일 판정 (달력 색상용).
- 고정 양력 공휴일은 매년 동일 → 연도 무관 판정.
- 음력 공휴일(설날/부처님오신날/추석)과 대체공휴일은 매년 달라짐 →
연도별 dict(`_LUNAR_AND_SUBSTITUTE`)에 명시. 새 연도는 KASI 발표값을 추가한다.
미수록 연도는 고정 양력 공휴일만 빨강 처리된다(음력/대체는 누락).
"""
from __future__ import annotations
from datetime import date
# 매년 동일한 양력 공휴일 (month, day)
_FIXED_SOLAR: set[tuple[int, int]] = {
(1, 1), # 신정
(3, 1), # 삼일절
(5, 5), # 어린이날
(6, 6), # 현충일
(8, 15), # 광복절
(10, 3), # 개천절
(10, 9), # 한글날
(12, 25), # 성탄절
}
# 연도별 음력 공휴일 + 대체공휴일 (ISO 날짜 문자열). KASI 발표 기준.
_LUNAR_AND_SUBSTITUTE: dict[int, set[str]] = {
2025: {
"2025-01-28", "2025-01-29", "2025-01-30", # 설날 연휴
"2025-03-03", # 삼일절 대체(3/1 토)
"2025-05-06", # 부처님오신날 대체(5/5 겹침)
"2025-05-05", # 부처님오신날(어린이날과 동일일)
"2025-10-06", "2025-10-07", "2025-10-08", # 추석 연휴
"2025-10-08", # 추석 대체 가능
},
2026: {
"2026-02-16", "2026-02-17", "2026-02-18", # 설날 연휴 (설날 2/17)
"2026-03-02", # 삼일절 대체 (3/1 일)
"2026-05-24", # 부처님오신날 (일)
"2026-05-25", # 부처님오신날 대체
"2026-08-17", # 광복절 대체 (8/15 토)
"2026-09-24", "2026-09-25", "2026-09-26", # 추석 연휴 (추석 9/25)
"2026-09-28", # 추석 대체 (9/26 토)
"2026-10-05", # 개천절 대체 (10/3 토)
},
2027: {
"2027-02-06", "2027-02-07", "2027-02-08", # 설날 연휴 (설날 2/7)
"2027-02-09", # 설날 대체 (2/7 일)
"2027-05-13", # 부처님오신날 (목)
"2027-08-16", # 광복절 대체 (8/15 일)
"2027-09-14", "2027-09-15", "2027-09-16", # 추석 연휴 (추석 9/15)
"2027-10-04", # 개천절 대체 (10/3 일)
"2027-10-11", # 한글날 대체 (10/9 토)
},
}
def is_holiday(d: date) -> bool:
"""공휴일(일요일 제외)이면 True. 일/토 색상은 요일로 따로 판정한다."""
if (d.month, d.day) in _FIXED_SOLAR:
return True
return d.isoformat() in _LUNAR_AND_SUBSTITUTE.get(d.year, set())
+155
View File
@@ -0,0 +1,155 @@
r"""itemcode_db 읽기 전용 상품 검색.
cupang 모듈은 itemcode_db 의 상품(낱개코드/세트코드)을 **읽기만** 한다.
cupang_db 에 상품을 복제 저장하지 않는다. 출고 라인에는 product_code 와
product_name_snapshot 만 보존한다.
⚠️ itemcode_db 의 실제 테이블/컬럼명은 이 저장소(main-app)에 정의되어 있지 않다.
운영 서버에서 다음으로 먼저 구조를 확인한 뒤 환경변수를 설정해야 한다:
docker exec -it postgres-db psql -U postgres -d itemcode_db -c "\dt"
docker exec -it postgres-db psql -U postgres -d itemcode_db -c "\d <테이블명>"
환경변수 (모두 미설정 시 검색 비활성 → 폼에서 수동 입력으로 폴백):
ITEMCODE_DB_URL 읽기 전용 DSN. 예: postgresql://itemcode_ro:<pwd>@postgres-db:5432/itemcode_db
ITEMCODE_SEARCH_SQL (선택) 검색 SQL 직접 지정. 아래 자동 생성 대신 사용.
반드시 code, name, type 컬럼을 별칭으로 반환하고,
%(q)s 파라미터를 LIKE 패턴으로 받는다.
자동 생성용 (ITEMCODE_SEARCH_SQL 미설정 시):
ITEMCODE_TABLE 검색 대상 테이블/뷰 (예: products 또는 item_master)
ITEMCODE_CODE_COL 코드 컬럼명 (기본: product_code)
ITEMCODE_NAME_COL 상품명 컬럼명 (기본: product_name)
ITEMCODE_TYPE_COL (선택) 단품/세트 구분 컬럼명. 없으면 type 은 빈 문자열.
낱개코드와 세트코드가 별도 테이블이면 ITEMCODE_SEARCH_SQL 에 UNION 으로 직접 작성한다.
"""
from __future__ import annotations
import logging
import os
import re
from typing import Any
logger = logging.getLogger("cupang.itemcode")
# 안전한 SQL 식별자(테이블/컬럼)만 허용 — 인젝션 방지.
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
def _ident(value: str, *, what: str) -> str:
v = (value or "").strip()
if not _IDENT_RE.match(v):
raise ValueError(f"안전하지 않은 {what} 식별자: {value!r}")
return v
class ItemcodeReader:
"""itemcode_db 읽기 전용 커넥션 풀 + 상품 검색.
설정이 없거나 불완전하면 `enabled=False` 로 두고, search()는 빈 리스트를 반환한다.
앱 부팅이나 cupang 모듈 진입을 막지 않는다.
"""
def __init__(self) -> None:
self._pool: Any = None
self._sql: str | None = None
self.enabled = False
self.reason = ""
self.last_error = "" # 마지막 조회 오류(진단용, 비밀값 없음)
self._configure()
def _configure(self) -> None:
dsn = os.getenv("ITEMCODE_DB_URL", "").strip()
if not dsn:
self.reason = "ITEMCODE_DB_URL 미설정 — 상품 검색 비활성(수동 입력 사용)."
return
custom_sql = os.getenv("ITEMCODE_SEARCH_SQL", "").strip()
if custom_sql:
self._sql = custom_sql
else:
table = os.getenv("ITEMCODE_TABLE", "").strip()
if not table:
self.reason = (
"ITEMCODE_TABLE(또는 ITEMCODE_SEARCH_SQL) 미설정 — "
"상품 검색 비활성(수동 입력 사용)."
)
return
try:
table_id = _ident(table, what="테이블")
code_col = _ident(os.getenv("ITEMCODE_CODE_COL", "product_code"), what="코드 컬럼")
name_col = _ident(os.getenv("ITEMCODE_NAME_COL", "product_name"), what="상품명 컬럼")
type_col_raw = os.getenv("ITEMCODE_TYPE_COL", "").strip()
type_expr = _ident(type_col_raw, what="구분 컬럼") if type_col_raw else "''"
except ValueError as exc:
self.reason = f"itemcode 검색 설정 오류: {exc}"
return
self._sql = (
f"SELECT {code_col} AS code, {name_col} AS name, {type_expr} AS type "
f"FROM {table_id} "
f"WHERE {code_col} ILIKE %(q)s OR {name_col} ILIKE %(q)s "
f"ORDER BY {code_col} ASC LIMIT %(limit)s"
)
# 풀은 lazy open — 부팅 시 itemcode_db 가 잠시 끊겨도 죽지 않게.
try:
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
self._pool = ConnectionPool(
conninfo=dsn,
min_size=1,
max_size=3,
kwargs={"row_factory": dict_row, "autocommit": True},
open=False,
)
self._pool.open(wait=False)
self.enabled = True
self.reason = ""
except Exception as exc: # noqa: BLE001 — 설정/드라이버 문제로 모듈을 죽이지 않음
self.reason = f"itemcode_db 연결 풀 생성 실패: {type(exc).__name__}"
def search(self, query: str, *, limit: int = 20) -> list[dict[str, Any]]:
"""code/name 부분 일치 검색. 반환: [{"code","name","type"}].
비활성 상태이거나 조회 실패 시 빈 리스트(예외 비전파 — UI 는 수동 입력 폴백).
"""
q = (query or "").strip()
if not q:
return []
return self._run(f"%{q}%", limit)
def list_all(self, *, limit: int = 2000) -> list[dict[str, Any]]:
"""전체 상품 목록(낱개+세트). 설정 화면 왼쪽 리스트용."""
return self._run("%", limit)
def _run(self, like: str, limit: int) -> list[dict[str, Any]]:
if not self.enabled or not self._pool or not self._sql:
return []
try:
with self._pool.connection() as conn:
rows = conn.execute(
self._sql, {"q": like, "limit": int(limit)}
).fetchall()
self.last_error = ""
except Exception as exc: # noqa: BLE001 — 모듈을 죽이지 않음. 원인은 로그 + last_error.
self.last_error = f"{type(exc).__name__}: {exc}"
logger.exception("itemcode 조회 실패 (SQL/스키마 확인 필요)")
return []
out: list[dict[str, Any]] = []
for r in rows:
out.append(
{
"code": str(r.get("code") or "").strip(),
"name": str(r.get("name") or "").strip(),
"type": str(r.get("type") or "").strip(),
}
)
return out
def close(self) -> None:
if self._pool is not None:
self._pool.close()
+701
View File
@@ -0,0 +1,701 @@
"""쿠팡 밀크런 모듈 라우터.
- 경로: /cupang
- 권한: 로그인 + `cupang` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
- 데이터: CupangDBStore (cupang_db / PostgreSQL) 전용.
CUPANG_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내를 보여준다.
- 상품 검색: itemcode_db 읽기 전용(ItemcodeReader). 미설정 시 수동 입력 폴백.
"""
from __future__ import annotations
import calendar as _calendar
import json
from typing import Any
from app.timezone import today_kst
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from .holidays import is_holiday
from .store import SHIP_METHODS
router = APIRouter(prefix="/cupang", tags=["cupang"])
# ────────────────────────────────────────────────────────────
# 공용 헬퍼
# ────────────────────────────────────────────────────────────
def _store(request: Request) -> Any:
"""CupangDBStore 또는 None(CUPANG_DB_URL 미설정)."""
return getattr(request.app.state, "cupang_store", None)
def _itemcode(request: Request) -> Any:
return getattr(request.app.state, "itemcode_reader", 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, "cupang"):
raise HTTPException(status_code=403, detail="쿠팡 밀크런 모듈 권한이 없습니다.")
return 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": "쿠팡 밀크런 모듈이 아직 설정되지 않았습니다. "
"CUPANG_DB_URL 환경변수를 설정하고 scripts/sql/cupang_db_init.sql 로 "
"cupang_db 를 초기화한 뒤 컨테이너를 재기동하세요.",
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
},
status_code=503,
)
def _guard(request: Request) -> tuple[Any, dict[str, Any]] | HTMLResponse | RedirectResponse:
"""로그인+권한+store 점검을 한 번에. 페이지 핸들러 진입부에서 사용."""
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, "cupang"):
return render_template(
request,
"denied.html",
{"reason": "쿠팡 밀크런 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)},
status_code=403,
)
store = _store(request)
if store is None:
return _render_config_needed(request, user)
return store, user
def _parse_lines(lines_json: str) -> list[dict[str, Any]]:
try:
data = json.loads(lines_json or "[]")
except (json.JSONDecodeError, TypeError):
raise HTTPException(status_code=400, detail="라인 데이터 형식 오류")
if not isinstance(data, list):
raise HTTPException(status_code=400, detail="라인 데이터는 배열이어야 합니다.")
return data
def _ym(request: Request) -> tuple[int, int]:
today = today_kst()
try:
year = int(request.query_params.get("year") or today.year)
month = int(request.query_params.get("month") or today.month)
except ValueError:
year, month = today.year, today.month
if not (1 <= month <= 12):
year, month = today.year, today.month
return year, month
# ════════════════════════════════════════════════════════════
# 메인 — 월간 달력 + 선택일 출고 리스트
# ════════════════════════════════════════════════════════════
@router.get("/", response_class=HTMLResponse)
async def index(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
year, month = _ym(request)
counts = store.calendar_counts(year=year, month=month)
shipments = store.list_shipments(year=year, month=month)
# 선택 날짜 (기본: 오늘이 이번 달이면 오늘, 아니면 1일)
sel = request.query_params.get("date") or ""
today = today_kst()
if not sel:
sel = today.isoformat() if (today.year == year and today.month == month) else f"{year:04d}-{month:02d}-01"
# 선택일에 걸친 묶음(출고일 기준 우선, 작성/입고 포함)
sel_shipments = [
s for s in shipments
if sel in (s.get("ship_date"), s.get("document_date"), s.get("center_arrival_date"))
]
# 각 묶음에 품목 요약(제품명/수량) 첨부 — hover 툴팁용
for s in sel_shipments:
full = store.get_shipment(shipment_id=s["id"])
s["tip_items"] = [
{"name": ln.get("product_name_snapshot") or ln.get("product_code"),
"qty": ln.get("quantity", 0)}
for ln in (full.get("lines") if full else [])
]
cal = _calendar.Calendar(firstweekday=6) # 일요일 시작
weeks = cal.monthdatescalendar(year, month)
cal_weeks = [
[
{
"date": d.isoformat(),
"day": d.day,
"in_month": d.month == month,
"is_today": d == today,
"is_selected": d.isoformat() == sel,
"is_sunday": d.weekday() == 6,
"is_saturday": d.weekday() == 5,
"is_holiday": is_holiday(d),
"counts": counts.get(d.isoformat(), {}),
}
for d in week
]
for week in weeks
]
prev_y, prev_m = (year - 1, 12) if month == 1 else (year, month - 1)
next_y, next_m = (year + 1, 1) if month == 12 else (year, month + 1)
return render_template(
request,
"cupang/index.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런",
"page_subtitle": f"{year}{month}월 출고 일정",
"year": year,
"month": month,
"prev_y": prev_y, "prev_m": prev_m,
"next_y": next_y, "next_m": next_m,
"weekdays": ["", "", "", "", "", "", ""],
"cal_weeks": cal_weeks,
"selected_date": sel,
"sel_shipments": sel_shipments,
},
)
# ════════════════════════════════════════════════════════════
# 출고 묶음 — 등록 / 수정 / 상세
# ════════════════════════════════════════════════════════════
def _form_context(request: Request, store: Any, user: dict[str, Any]) -> dict[str, Any]:
from app.main import build_erp_nav # noqa: WPS433
from app.store import is_admin # noqa: WPS433
reader = _itemcode(request)
return {
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"centers": sorted(store.list_centers(), key=lambda c: c["name"]),
"box_rules": store.list_box_rules(),
"products": store.list_products(),
"ship_methods": list(SHIP_METHODS),
"search_enabled": bool(reader and reader.enabled),
}
@router.get("/new", response_class=HTMLResponse)
async def new_form(request: Request) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
store, user = guard
ctx = _form_context(request, store, user)
ctx.update(
{
"page_title": "쿠팡 밀크런 — 신규 등록",
"page_subtitle": "공통 헤더 1개 + 품목 라인",
"mode": "new",
"shipment": None,
"default_date": today_kst().isoformat(),
}
)
return render_template(request, "cupang/form.html", ctx)
@router.post("/new")
async def create(
request: Request,
lines_json: str = Form("[]"),
document_date: str = Form(...),
ship_date: str = Form(...),
center_arrival_date: str = Form(...),
center_id: str = Form(""),
center_name_snapshot: str = Form(""),
ship_method: str = Form("택배"),
outbound_summary: str = Form(""),
worker: str = Form(""),
memo: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
header = {
"document_date": document_date,
"ship_date": ship_date,
"center_arrival_date": center_arrival_date,
"center_id": center_id,
"center_name_snapshot": center_name_snapshot,
"ship_method": ship_method,
"outbound_summary": outbound_summary,
"worker": worker,
"memo": memo,
}
try:
ship = store.create_shipment(
created_by=user["email"], header=header, lines=_parse_lines(lines_json)
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/cupang/{ship['id']}", status_code=303)
@router.get("/{shipment_id:int}", response_class=HTMLResponse)
async def detail(request: Request, shipment_id: int) -> 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
ship = store.get_shipment(shipment_id=shipment_id)
if not ship:
return render_template(
request, "denied.html",
{"reason": "출고 묶음을 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
return render_template(
request,
"cupang/detail.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": f"출고 #{ship['id']}",
"page_subtitle": f"{ship['ship_date']} · {ship['center_name_snapshot']}",
"shipment": ship,
},
)
@router.get("/{shipment_id:int}/edit", response_class=HTMLResponse)
async def edit_form(request: Request, shipment_id: int) -> HTMLResponse:
from app.main import 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
ship = store.get_shipment(shipment_id=shipment_id)
if not ship:
return render_template(
request, "denied.html",
{"reason": "출고 묶음을 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
ctx = _form_context(request, store, user)
ctx.update(
{
"page_title": f"출고 #{ship['id']} 수정",
"page_subtitle": "헤더/라인 수정 후 저장",
"mode": "edit",
"shipment": ship,
"default_date": ship["document_date"],
}
)
return render_template(request, "cupang/form.html", ctx)
@router.post("/{shipment_id:int}/edit")
async def update(
request: Request,
shipment_id: int,
lines_json: str = Form("[]"),
document_date: str = Form(...),
ship_date: str = Form(...),
center_arrival_date: str = Form(...),
center_id: str = Form(""),
center_name_snapshot: str = Form(""),
ship_method: str = Form("택배"),
outbound_summary: str = Form(""),
worker: str = Form(""),
memo: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
header = {
"document_date": document_date,
"ship_date": ship_date,
"center_arrival_date": center_arrival_date,
"center_id": center_id,
"center_name_snapshot": center_name_snapshot,
"ship_method": ship_method,
"outbound_summary": outbound_summary,
"worker": worker,
"memo": memo,
}
try:
store.update_shipment(
shipment_id=shipment_id, header=header, lines=_parse_lines(lines_json)
)
except KeyError:
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/cupang/{shipment_id}", status_code=303)
@router.post("/{shipment_id:int}/delete")
async def delete(
request: Request,
shipment_id: int,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
"""운영 안전: 기본은 status='취소' soft delete."""
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.soft_delete(shipment_id=shipment_id)
except KeyError:
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
return RedirectResponse(url=f"/cupang/{shipment_id}", status_code=303)
@router.post("/{shipment_id:int}/hard-delete")
async def hard_delete(
request: Request,
shipment_id: int,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
"""완전 삭제(헤더+라인 CASCADE). 달력으로 복귀."""
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.hard_delete(shipment_id=shipment_id)
except KeyError:
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
return RedirectResponse(url="/cupang/", status_code=303)
# ════════════════════════════════════════════════════════════
# 입고센터 관리
# ════════════════════════════════════════════════════════════
@router.get("/centers", response_class=HTMLResponse)
async def centers_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
centers = sorted(store.list_centers(include_inactive=True), key=lambda c: c["name"])
# 사용 중 여부 표시
for c in centers:
c["in_use"] = store.center_in_use(center_id=c["id"])
return render_template(
request,
"cupang/centers.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런 — 입고센터 관리",
"page_subtitle": "추가 · 수정 · 비활성화. 사용 중 센터는 삭제되지 않고 비활성화됩니다.",
"centers": centers,
},
)
@router.post("/centers")
async def center_create(
request: Request,
name: str = Form(...),
sort_order: int = Form(0),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.create_center(name=name, sort_order=sort_order)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url="/cupang/centers", status_code=303)
@router.post("/centers/{center_id}/edit")
async def center_edit(
request: Request,
center_id: int,
name: str = Form(""),
active: str = Form(""),
sort_order: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
kwargs: dict[str, Any] = {"center_id": center_id}
if name.strip():
kwargs["name"] = name
if active != "":
kwargs["active"] = active in ("1", "true", "on", "True")
if sort_order.strip():
try:
kwargs["sort_order"] = int(sort_order)
except ValueError:
pass
try:
store.update_center(**kwargs)
except KeyError:
raise HTTPException(status_code=404, detail="센터를 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url="/cupang/centers", status_code=303)
@router.post("/centers/{center_id}/delete")
async def center_delete(
request: Request,
center_id: int,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.delete_center(center_id=center_id)
except KeyError:
raise HTTPException(status_code=404, detail="센터를 찾을 수 없습니다.")
return RedirectResponse(url="/cupang/centers", status_code=303)
# ════════════════════════════════════════════════════════════
# 박스 입수량 관리
# ════════════════════════════════════════════════════════════
@router.get("/box-rules", response_class=HTMLResponse)
async def box_rules_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_rules.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런 — 박스 입수량",
"page_subtitle": "제품코드별 쿠팡박스 1박스당 입수량 설정",
"box_rules": store.list_box_rules(include_inactive=True),
"products": store.list_products(),
"search_enabled": bool((_itemcode(request)) and _itemcode(request).enabled),
},
)
@router.post("/box-rules")
async def box_rule_upsert(
request: Request,
product_code: str = Form(...),
units_per_box: int = Form(...),
product_name_snapshot: str = Form(""),
box_name: str = Form("쿠팡박스"),
memo: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.upsert_box_rule(
product_code=product_code,
units_per_box=units_per_box,
product_name_snapshot=product_name_snapshot,
box_name=box_name,
memo=memo,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url="/cupang/box-rules", status_code=303)
@router.post("/box-rules/{rule_id}/delete")
async def box_rule_delete(
request: Request,
rule_id: int,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.delete_box_rule(rule_id=rule_id)
except KeyError:
raise HTTPException(status_code=404, detail="규칙을 찾을 수 없습니다.")
return RedirectResponse(url="/cupang/box-rules", status_code=303)
# ════════════════════════════════════════════════════════════
# 설정 — 제품명 카탈로그 관리 (itemcode_db 에서 등록)
# ════════════════════════════════════════════════════════════
@router.get("/products", response_class=HTMLResponse)
async def products_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
reader = _itemcode(request)
return render_template(
request,
"cupang/products.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런 — 설정 (제품명)",
"page_subtitle": "왼쪽 itemcode_db 목록에서 선택해 등록하면 폼 드롭다운에 노출됩니다.",
"products": store.list_products(include_inactive=True),
"registered_codes": [p["product_code"] for p in store.list_products(include_inactive=True)],
"search_enabled": bool(reader and reader.enabled),
"search_reason": (reader.reason if reader else ""),
},
)
@router.get("/api/products/all")
async def product_all(
request: Request, _: dict[str, Any] = Depends(_require_user)
) -> JSONResponse:
"""itemcode_db 전체 상품 목록(낱개+세트). 설정 화면 왼쪽 리스트 소스."""
reader = _itemcode(request)
results = reader.list_all() if reader else []
return JSONResponse(
{
"enabled": bool(reader and reader.enabled),
"reason": (reader.reason if reader else "itemcode 리더 미초기화"),
"error": (getattr(reader, "last_error", "") if reader else ""),
"count": len(results),
"results": results,
}
)
@router.post("/products/bulk")
async def product_bulk(
request: Request,
items: list[dict[str, Any]] = Body(..., embed=True),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
"""선택한 상품들을 일괄 등록(upsert). body: {"items":[{"code","name"}, ...]}."""
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
added = 0
for it in items:
code = str(it.get("code") or "").strip()
name = str(it.get("name") or "").strip()
if not code or not name:
continue
try:
store.upsert_product(product_code=code, product_name=name)
added += 1
except ValueError:
continue
return JSONResponse({"ok": True, "added": added})
@router.post("/products")
async def product_upsert(
request: Request,
product_code: str = Form(...),
product_name: str = Form(...),
sort_order: int = Form(0),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.upsert_product(
product_code=product_code, product_name=product_name, sort_order=sort_order
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url="/cupang/products", status_code=303)
@router.post("/products/{product_id:int}/active")
async def product_set_active(
request: Request,
product_id: int,
active: str = Form(...),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.set_product_active(
product_id=product_id, active=active in ("1", "true", "on", "True")
)
except KeyError:
raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.")
return RedirectResponse(url="/cupang/products", status_code=303)
@router.post("/products/{product_id:int}/delete")
async def product_delete(
request: Request,
product_id: int,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
"""완전 삭제(hard delete)."""
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.delete_product(product_id=product_id)
except KeyError:
raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.")
return RedirectResponse(url="/cupang/products", status_code=303)
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "module": "cupang"}
+96
View File
@@ -0,0 +1,96 @@
"""쿠팡 밀크런 모듈 상수 및 공용 헬퍼.
- 데이터 저장은 cupang_db(PostgreSQL) 전용이다(`db.py`).
운영 데이터가 JSON 과 DB 로 갈라지는 것을 막기 위해 JSON 폴백을 두지 않는다.
CUPANG_DB_URL 미설정 시 라우터가 "설정 필요" 안내 페이지를 보여준다.
- 이 모듈에는 DB/JSON 양쪽이 공유하는 상수와 순수 계산 헬퍼만 둔다.
"""
from __future__ import annotations
import math
from typing import Any
# 출고 묶음 상태 (expense 의 STATUSES 패턴과 동일하게 한글 라벨 그대로 저장)
STATUSES: tuple[str, ...] = (
"작성중",
"출고준비",
"출고완료",
"센터입고완료",
"취소",
)
# 출고방식 기본 후보 (자유 입력 허용, 아래는 select 기본값)
SHIP_METHODS: tuple[str, ...] = ("택배", "직접배송", "화물", "파렛트", "기타")
# 초기 입고센터 seed — cupang_db_init.sql 에도 동일 목록을 INSERT 한다.
# 화면에서 추가/수정/비활성화 가능. 사용 중인 센터는 hard delete 하지 않는다.
DEFAULT_CENTERS: tuple[str, ...] = (
"대구3",
"인천32",
"이천1",
"인천42",
"인천26",
"인천16",
"인천28",
"안성8",
"천안8(RC)",
"시흥2",
"인천36",
"MGMH5",
"XRC10(RC)",
"인천14",
"경기광주5",
"경기광주3",
"XRC06(RC)",
"용인1",
"인천30",
"마장1",
"안성4",
"대구6",
"전라광주2",
"창원1",
"고양1",
"동탄1",
"이천4",
"XRC09(RC)",
)
def compute_boxes(quantity: int, units_per_box: int | None) -> dict[str, Any]:
"""수량 + 박스당 입수량으로 필요한 박스 수를 계산한다.
클라이언트 계산을 신뢰하지 않고 서버에서 이 함수로 재계산한다.
- units_per_box 가 없거나 0 이하면 "미설정" — 자동 계산하지 않는다.
- full_boxes = quantity // units_per_box
- remainder_units = quantity % units_per_box
- required_boxes = ceil(quantity / units_per_box)
"""
try:
qty = int(quantity)
except (TypeError, ValueError):
qty = 0
upb: int | None
try:
upb = int(units_per_box) if units_per_box is not None else None
except (TypeError, ValueError):
upb = None
if not upb or upb <= 0 or qty <= 0:
return {
"configured": False,
"units_per_box": upb if (upb and upb > 0) else None,
"full_boxes": None,
"remainder_units": None,
"required_boxes": None,
}
return {
"configured": True,
"units_per_box": upb,
"full_boxes": qty // upb,
"remainder_units": qty % upb,
"required_boxes": math.ceil(qty / upb),
}
@@ -0,0 +1,106 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
{% block content %}
<section class="cpg">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
</div>
<div class="cpg-brule-layout">
<!-- 왼쪽: 추가/수정 (product_code UNIQUE → upsert) -->
<div class="erp-card cpg-form-card cpg-brule-add">
<div class="cpg-card-head">
<h2>박스 입수량 추가 / 수정</h2>
<span class="erp-muted">같은 제품코드는 덮어씁니다.</span>
</div>
<form method="post" action="/cupang/box-rules">
<input type="hidden" name="product_name_snapshot" id="brule-name-snap" />
<div class="cpg-brule-fields">
<div class="cpg-brule-row">
<label class="erp-field"><span>제품명 *</span>
<select class="erp-select cpg-brule-name" id="brule-name" required>
<option value="">— 제품명 선택 —</option>
{% for p in products %}
<option value="{{ p.product_code }}" data-name="{{ p.product_name }}">{{ p.product_name }}</option>
{% endfor %}
</select></label>
<label class="erp-field"><span>제품코드</span>
<input class="erp-input cpg-brule-code" type="text" name="product_code" id="brule-code" required placeholder="자동" /></label>
</div>
<div class="cpg-brule-row">
<label class="erp-field"><span>박스이름</span>
<input class="erp-input cpg-brule-box" type="text" name="box_name" value="쿠팡박스" /></label>
<label class="erp-field"><span>박스당 입수량 *</span>
<span class="cpg-upb-wrap">
<input class="erp-input cpg-brule-upb" type="number" name="units_per_box" min="1" required />
<span class="cpg-upb-unit"></span>
</span></label>
</div>
<div class="cpg-brule-row">
<label class="erp-field cpg-brule-memo-field"><span>메모</span>
<input class="erp-input cpg-brule-memo" type="text" name="memo" /></label>
</div>
</div>
<div class="erp-page-actions" style="margin-top:12px;">
<button type="submit" class="erp-btn erp-btn-primary">저장</button>
</div>
</form>
{% if not products %}
<p class="erp-muted"><a href="/cupang/products">설정에서 제품명을 먼저 등록</a>하면 드롭다운에 표시됩니다.</p>
{% endif %}
<script>
(function () {
var sel = document.getElementById("brule-name");
var code = document.getElementById("brule-code");
var snap = document.getElementById("brule-name-snap");
if (!sel) return;
sel.addEventListener("change", function () {
var opt = sel.options[sel.selectedIndex];
code.value = sel.value;
snap.value = opt ? (opt.getAttribute("data-name") || "") : "";
});
})();
</script>
</div>
<!-- 오른쪽: 목록 -->
<div class="erp-card cpg-form-card cpg-brule-list">
<div class="cpg-card-head"><h2>입수량 규칙 ({{ box_rules|length }})</h2></div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr><th>제품명</th><th>제품코드</th><th>박스명</th><th>입수량</th><th>메모</th><th>동작</th></tr>
</thead>
<tbody>
{% for r in box_rules %}
<tr>
<td>{{ r.product_name_snapshot or '—' }}</td>
<td>{{ r.product_code }}</td>
<td>{{ r.box_name }}</td>
<td>{{ r.units_per_box }}개</td>
<td>{{ r.memo or '—' }}</td>
<td>
<form method="post" action="/cupang/box-rules/{{ r.id }}/delete" class="cpg-inline-form"
onsubmit="return confirm('이 규칙을 삭제합니다. 계속할까요?');">
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
</form>
</td>
</tr>
{% endfor %}
{% if not box_rules %}
<tr><td colspan="6" class="erp-muted">등록된 입수량 규칙이 없습니다.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div><!-- /cpg-brule-layout -->
</section>
{% endblock %}
@@ -0,0 +1,66 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
{% block content %}
<section class="cpg">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
</div>
<div class="cpg-center-layout">
<!-- 왼쪽: 입고센터 추가 -->
<div class="erp-card cpg-form-card cpg-center-add">
<h2 class="cpg-center-add-title">입고센터 추가</h2>
<form method="post" action="/cupang/centers" class="cpg-inline-form">
<input class="erp-input" type="text" name="name" placeholder="센터명 (예: 대구3)" required style="flex:1 1 auto;min-width:0" />
<button type="submit" class="erp-btn erp-btn-primary">추가</button>
</form>
</div>
<!-- 오른쪽: 센터 목록 (높이 900 고정, 내부 스크롤) -->
<div class="erp-card cpg-form-card cpg-center-listcard">
<div class="cpg-card-head">
<h2>센터 목록 ({{ centers|length }})</h2>
<span class="erp-muted">사용 중 센터는 삭제 시 비활성화됩니다.</span>
</div>
<div class="cpg-center-list">
{% for c in centers %}
<div class="cpg-center-row {% if not c.active %}is-inactive{% endif %}">
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-center-edit">
<input class="erp-input cpg-center-name" type="text" name="name" value="{{ c.name }}" />
<button type="submit" class="erp-btn erp-btn-outline cpg-btn-sm" title="이름 저장">저장</button>
</form>
<span class="cpg-center-state">
{% if c.in_use %}<span class="erp-badge erp-badge-inverse cpg-mini">사용중</span>{% endif %}
{% if c.active %}<span class="erp-badge erp-badge-success cpg-mini">활성</span>
{% else %}<span class="erp-badge erp-badge-neutral cpg-mini">비활성</span>{% endif %}
</span>
<span class="cpg-center-act">
{% if c.active %}
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-inline-form">
<input type="hidden" name="active" value="0" />
<button type="submit" class="erp-btn erp-btn-outline cpg-btn-sm">비활성</button>
</form>
{% else %}
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-inline-form">
<input type="hidden" name="active" value="1" />
<button type="submit" class="erp-btn erp-btn-outline cpg-btn-sm">활성</button>
</form>
{% endif %}
<form method="post" action="/cupang/centers/{{ c.id }}/delete" class="cpg-inline-form"
onsubmit="return confirm('{% if c.in_use %}사용 중 → 비활성화됩니다.{% else %}삭제합니다.{% endif %} 계속?');">
<button type="submit" class="erp-btn erp-btn-danger cpg-btn-sm">삭제</button>
</form>
</span>
</div>
{% endfor %}
</div>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,76 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
{% block content %}
<section class="cpg">
<div class="erp-page-actions cpg-actions">
<a class="erp-btn erp-btn-primary" href="/cupang/?date={{ shipment.ship_date }}">◀◀ 달력</a>
<form method="post" action="/cupang/{{ shipment.id }}/delete" class="cpg-inline-form"
onsubmit="return confirm('이 출고 묶음을 취소 처리합니다(상태=취소). 계속할까요?');">
<button type="submit" class="erp-btn erp-btn-outline">취소 처리</button>
</form>
<form method="post" action="/cupang/{{ shipment.id }}/hard-delete" class="cpg-inline-form"
onsubmit="return confirm('이 출고를 완전 삭제합니다(복구 불가, 품목 포함). 계속할까요?');">
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
</form>
<a class="erp-btn erp-btn-primary cpg-push-right" href="/cupang/{{ shipment.id }}/edit">수정</a>
</div>
<!-- 헤더 -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head">
<h2>출고 #{{ shipment.id }}
{% set badge = 'erp-badge-neutral' %}
{% if shipment.status == '출고완료' %}{% set badge = 'erp-badge-inverse' %}
{% elif shipment.status == '센터입고완료' %}{% set badge = 'erp-badge-success' %}
{% elif shipment.status == '취소' %}{% set badge = 'erp-badge-danger' %}{% endif %}
<span class="erp-badge {{ badge }}">{{ shipment.status }}</span>
</h2>
</div>
<dl class="cpg-detail-grid">
<div><dt>작성일</dt><dd>{{ shipment.document_date }}</dd></div>
<div><dt>출고일</dt><dd>{{ shipment.ship_date }}</dd></div>
<div><dt>센터입고일</dt><dd>{{ shipment.center_arrival_date }}</dd></div>
<div><dt>입고센터</dt><dd>{{ shipment.center_name_snapshot or '—' }}</dd></div>
<div><dt>출고방식</dt><dd>{{ shipment.ship_method }}</dd></div>
<div><dt>작업자</dt><dd>{{ shipment.worker or '—' }}</dd></div>
<div class="cpg-full"><dt>출고/박스 요약</dt><dd>{{ shipment.outbound_summary or '—' }}</dd></div>
<div class="cpg-full"><dt>메모</dt><dd>{{ shipment.memo or '—' }}</dd></div>
</dl>
</div>
<!-- 라인 -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head"><h2>품목 ({{ shipment.lines|length }})</h2></div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr><th>#</th><th>제품코드</th><th>제품명</th><th>수량</th>
<th>입수량</th><th>필요박스</th><th>잔량</th><th>수동보정</th><th>메모</th></tr>
</thead>
<tbody>
{% for ln in shipment.lines %}
<tr>
<td>{{ ln.line_no }}</td>
<td>{{ ln.product_code }}</td>
<td>{{ ln.product_name_snapshot }}</td>
<td>{{ ln.quantity }}</td>
<td>{{ ln.units_per_box if ln.units_per_box else '미설정' }}</td>
<td>{{ ln.calculated_boxes if ln.calculated_boxes is not none else '—' }}</td>
<td>{{ ln.remainder_units if ln.remainder_units is not none else '—' }}</td>
<td>{{ ln.manual_box_text or '—' }}</td>
<td>{{ ln.memo or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,112 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
{% block content %}
<section class="cpg">
{% set action = '/cupang/new' if mode == 'new' else '/cupang/' ~ shipment.id ~ '/edit' %}
<form id="cpg-form" method="post" action="{{ action }}">
<input type="hidden" name="lines_json" id="cpg-lines-json" value="[]" />
<div class="cpg-form-2col">
<!-- ── 공통 헤더 (왼쪽) ── -->
<div class="erp-card cpg-form-card cpg-form-head">
<div class="cpg-card-head"><h2>공통 헤더</h2></div>
<div class="cpg-header-grid">
<label class="erp-field"><span>작성일 *</span>
<input class="erp-input" type="date" name="document_date" required
value="{{ shipment.document_date if shipment else default_date }}" /></label>
<label class="erp-field"><span>출고일 *</span>
<input class="erp-input" type="date" name="ship_date" required
value="{{ shipment.ship_date if shipment else default_date }}" /></label>
<label class="erp-field"><span>센터입고일 *</span>
<input class="erp-input" type="date" name="center_arrival_date" required
value="{{ shipment.center_arrival_date if shipment else default_date }}" /></label>
<label class="erp-field"><span>입고센터</span>
<select class="erp-select" name="center_id" id="cpg-center-select">
<option value="">— 선택 —</option>
{% for c in centers %}
<option value="{{ c.id }}" data-name="{{ c.name }}"
{% if shipment and shipment.center_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select></label>
<!-- 센터 스냅샷(자유 입력 허용: 과거 명칭 보존/센터 미등록 시) -->
<input type="hidden" name="center_name_snapshot" id="cpg-center-name"
value="{{ shipment.center_name_snapshot if shipment else '' }}" />
<label class="erp-field"><span>출고방식</span>
<select class="erp-select" name="ship_method">
{% for m in ship_methods %}
<option value="{{ m }}" {% if shipment and shipment.ship_method == m %}selected{% endif %}>{{ m }}</option>
{% endfor %}
</select></label>
<label class="erp-field"><span>작업자</span>
<input class="erp-input" type="text" name="worker"
value="{{ shipment.worker if shipment else '' }}" /></label>
</div>
<label class="erp-field cpg-full"><span>출고/박스 요약 (수동 보정 메모)</span>
<input class="erp-input" type="text" name="outbound_summary"
placeholder="예: 쿠팡박스 50, 6호상자 1, (50번 박스)"
value="{{ shipment.outbound_summary if shipment else '' }}" /></label>
<label class="erp-field cpg-full"><span>메모</span>
<textarea class="erp-input" name="memo" rows="2">{{ shipment.memo if shipment else '' }}</textarea></label>
<div class="erp-page-actions cpg-form-actions">
<button type="submit" class="erp-btn erp-btn-primary">저장</button>
{% if mode == 'edit' %}
<a class="erp-btn erp-btn-outline" href="/cupang/{{ shipment.id }}">취소</a>
{% else %}
<a class="erp-btn erp-btn-outline" href="/cupang/">취소</a>
{% endif %}
</div>
</div>
<!-- ── 품목 라인 (오른쪽) ── -->
<div class="erp-card cpg-form-card cpg-form-lines">
<div class="cpg-card-head cpg-lines-head">
<h2>품목 라인</h2>
<span class="erp-muted">
제품명 선택 시 제품코드 자동 입력. 수량 입력 시 박스 수 자동 계산.
{% if not products %}<a href="/cupang/products">설정에서 제품명 먼저 등록</a>{% endif %}
</span>
<div class="cpg-lines-btns">
<button type="button" class="erp-btn erp-btn-outline" id="cpg-add-line">+ 라인 추가</button>
<button type="button" class="erp-btn erp-btn-danger" id="cpg-del-line">선택 라인 삭제</button>
</div>
</div>
<div class="erp-table-wrap cpg-lines-scroll">
<table class="erp-table cpg-lines">
<thead>
<tr>
<th class="cpg-check-col"><input type="checkbox" id="cpg-check-all" title="전체 선택" /></th>
<th>제품명</th><th>제품코드</th><th>수량</th>
<th>입수량</th><th>박스 계산</th><th>라인메모</th>
</tr>
</thead>
<tbody id="cpg-lines-body"><!-- JS 렌더 --></tbody>
</table>
</div>
</div>
</div><!-- /cpg-form-2col -->
</form>
</section>
<script type="application/json" id="cpg-init-lines">
{% if shipment and shipment.lines %}{{ shipment.lines | tojson }}{% else %}[]{% endif %}
</script>
<script type="application/json" id="cpg-box-rules">
{{ box_rules | tojson }}
</script>
<script type="application/json" id="cpg-products">
{{ products | tojson }}
</script>
{% endblock %}
{% block scripts %}<script src="/static/cupang.js?v=20260530p" defer></script>{% endblock %}
@@ -0,0 +1,139 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
{% block content %}
<section class="cpg">
<!-- 페이지 액션 (달력 컬럼 폭에 맞춤: 신규 좌측 / 설정 달력 오른쪽 끝) -->
<div class="cpg-actions-grid">
<div class="cpg-actions-main">
<a class="erp-btn erp-btn-primary" href="/cupang/new">+ 신규 등록</a>
<span class="cpg-settings-btns">
<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/box-rules">박스 입수량 설정</a>
</span>
</div>
<div class="cpg-actions-spacer"></div>
</div>
<div class="cpg-layout">
<!-- ── 왼쪽: 큰 월간 달력 ── -->
<div class="erp-card cpg-cal-card">
<div class="cpg-cal-head">
<a class="erp-btn erp-btn-outline cpg-nav-btn"
href="/cupang/?year={{ prev_y }}&month={{ prev_m }}"></a>
<h2 class="cpg-cal-title">{{ year }}년 {{ month }}월</h2>
<a class="erp-btn erp-btn-outline cpg-nav-btn"
href="/cupang/?year={{ next_y }}&month={{ next_m }}"></a>
</div>
<div class="cpg-cal-grid">
{% for wd in weekdays %}
<div class="cpg-cal-wd {% if loop.index0 == 0 %}cpg-sun{% elif loop.index0 == 6 %}cpg-sat{% endif %}">{{ wd }}</div>
{% endfor %}
{% for week in cal_weeks %}
{% for cell in week %}
<a class="cpg-cal-cell
{% if not cell.in_month %}cpg-out{% endif %}
{% if cell.is_today %}cpg-today{% endif %}
{% if cell.is_selected %}cpg-selected{% endif %}
{% if cell.is_sunday or cell.is_holiday %}cpg-red{% elif cell.is_saturday %}cpg-blue{% endif %}"
href="/cupang/?year={{ year }}&month={{ month }}&date={{ cell.date }}">
<span class="cpg-cal-day">{{ cell.day }}</span>
<span class="cpg-cal-badges">
{% if cell.counts.document %}<span class="erp-badge erp-badge-neutral cpg-mini">작성 {{ cell.counts.document }}</span>{% endif %}
{% if cell.counts.ship %}<span class="erp-badge erp-badge-inverse cpg-mini">출고 {{ cell.counts.ship }}</span>{% endif %}
{% if cell.counts.arrival %}<span class="erp-badge erp-badge-success cpg-mini">입고 {{ cell.counts.arrival }}</span>{% endif %}
</span>
</a>
{% endfor %}
{% endfor %}
</div>
</div>
<!-- ── 오른쪽: 선택일 출고 묶음 리스트 ── -->
<div class="erp-card cpg-list-card">
<div class="cpg-list-head">
<h2>{{ selected_date }} 출고</h2>
<span class="erp-muted">{{ sel_shipments|length }}건</span>
</div>
{% if sel_shipments %}
<ul class="cpg-list">
{% for s in sel_shipments %}
<li class="cpg-list-item">
<a href="/cupang/{{ s.id }}" class="cpg-list-link cpg-hover-item"
data-items='{{ s.tip_items | tojson }}'>
<div class="cpg-list-top">
<strong>{{ s.center_name_snapshot or '센터 미지정' }}</strong>
</div>
<div class="cpg-list-meta erp-muted">
출고 {{ s.ship_date }} · 입고 {{ s.center_arrival_date }} · {{ s.ship_method }}
{% if s.outbound_summary %}<br>{{ s.outbound_summary }}{% endif %}
</div>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="erp-muted cpg-empty">선택한 날짜의 출고 묶음이 없습니다.
<a href="/cupang/new">신규 등록</a></p>
{% endif %}
</div>
</div>
<!-- hover 툴팁 (마우스 따라다님, 커서 왼쪽) -->
<div id="cpg-hover-tip" class="cpg-hover-tip" hidden></div>
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
var tip = document.getElementById("cpg-hover-tip");
if (!tip) return;
function buildHtml(items) {
if (!items || !items.length) return '<div class="cpg-tip-empty">품목 없음</div>';
var html = "";
items.forEach(function (it) {
var d1 = document.createElement("div");
d1.className = "cpg-tip-row";
var n = document.createElement("span"); n.className = "cpg-tip-name"; n.textContent = it.name || "";
var q = document.createElement("span"); q.className = "cpg-tip-qty"; q.textContent = (it.qty != null ? it.qty : 0) + "개";
d1.appendChild(n); d1.appendChild(q);
html += d1.outerHTML;
});
return html;
}
function move(e) {
// 커서 왼쪽에 표시
var w = tip.offsetWidth || 200;
var x = e.clientX - w - 14;
if (x < 6) x = e.clientX + 16; // 화면 왼쪽 벗어나면 오른쪽으로
var y = e.clientY + 12;
var maxY = window.innerHeight - tip.offsetHeight - 8;
if (y > maxY) y = maxY;
tip.style.left = x + "px";
tip.style.top = y + "px";
}
document.querySelectorAll(".cpg-hover-item").forEach(function (el) {
el.addEventListener("mouseenter", function (e) {
var items = [];
try { items = JSON.parse(el.getAttribute("data-items") || "[]"); } catch (_) {}
tip.innerHTML = buildHtml(items);
tip.hidden = false;
move(e);
});
el.addEventListener("mousemove", move);
el.addEventListener("mouseleave", function () { tip.hidden = true; });
});
})();
</script>
{% endblock %}
@@ -0,0 +1,156 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
{% block content %}
<section class="cpg">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
</div>
<div class="cpg-prod-layout">
<!-- ── 왼쪽: 미라네 주방 상품 목록 (다중선택 → 등록) ── -->
<div class="erp-card cpg-form-card cpg-prod-left">
<div class="cpg-card-head">
<h2>미라네 주방 상품</h2>
<span class="erp-muted">
{% if search_enabled %}선택(다중) 후 등록. 이미 등록된 항목은 진한 회색.{% else %}
검색 비활성: {{ search_reason }}{% endif %}
</span>
</div>
{% if search_enabled %}
<div class="cpg-inline-form" style="margin-bottom:10px;">
<input class="erp-input" type="text" id="cpg-prod-q" placeholder="이름/코드 필터" style="min-width:200px" />
<button type="button" class="erp-btn erp-btn-primary" id="cpg-prod-register">선택 등록</button>
</div>
<div id="cpg-src-list" class="cpg-src-list"><p class="erp-muted">불러오는 중…</p></div>
{% else %}
<p class="erp-muted">ITEMCODE_DB_URL / ITEMCODE_SEARCH_SQL 설정 후 사용 가능합니다.</p>
{% endif %}
</div>
<!-- ── 오른쪽: 등록된 제품명 ── -->
<div class="erp-card cpg-form-card cpg-prod-right">
<div class="cpg-card-head"><h2>등록된 제품명 ({{ products|length }})</h2>
<span class="erp-muted">폼의 제품명 드롭다운에 노출</span></div>
<div class="erp-table-wrap cpg-reg-scroll">
<table class="erp-table">
<thead><tr><th>제품명</th><th>제품코드</th><th>상태</th><th>동작</th></tr></thead>
<tbody>
{% for p in products %}
<tr {% if not p.active %}style="opacity:.55"{% endif %}>
<td>{{ p.product_name }}</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>
<div class="cpg-row-actions">
{% if p.active %}
<form method="post" action="/cupang/products/{{ p.id }}/active" class="cpg-inline-form">
<input type="hidden" name="active" value="0" />
<button type="submit" class="erp-btn erp-btn-outline">비활성</button>
</form>
{% else %}
<form method="post" action="/cupang/products/{{ p.id }}/active" class="cpg-inline-form">
<input type="hidden" name="active" value="1" />
<button type="submit" class="erp-btn erp-btn-outline">활성</button>
</form>
{% endif %}
<form method="post" action="/cupang/products/{{ p.id }}/delete" class="cpg-inline-form"
onsubmit="return confirm('완전 삭제합니다. 계속할까요?');">
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% if not products %}
<tr><td colspan="4" class="erp-muted">등록된 제품명이 없습니다. 왼쪽에서 선택해 등록하세요.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</section>
{% if search_enabled %}
<script type="application/json" id="cpg-registered">{{ registered_codes | tojson }}</script>
<script>
(function () {
var listBox = document.getElementById("cpg-src-list");
var filter = document.getElementById("cpg-prod-q");
var regBtn = document.getElementById("cpg-prod-register");
var registered = {};
try { (JSON.parse(document.getElementById("cpg-registered").textContent || "[]")).forEach(function(c){ registered[c]=true; }); } catch(e){}
var all = [];
var selected = {};
function esc(s){ var d=document.createElement("div"); d.textContent=s||""; return d.innerHTML; }
function render() {
var term = (filter.value || "").trim().toLowerCase();
var rows = all.filter(function (it) {
if (!term) return true;
return (it.code + " " + it.name).toLowerCase().indexOf(term) >= 0;
});
if (!rows.length) { listBox.innerHTML = '<p class="erp-muted">결과 없음</p>'; return; }
var html = "";
rows.forEach(function (it) {
var cls = "cpg-src-item";
if (registered[it.code]) cls += " cpg-registered";
if (selected[it.code]) cls += " is-selected";
html += '<div class="' + cls + '" data-code="' + esc(it.code) + '">' +
'<span class="cpg-src-name">' + esc(it.name) + '</span>' +
'<span class="cpg-src-code">' + esc(it.code) + '</span>' +
'</div>';
});
listBox.innerHTML = html;
}
listBox.addEventListener("click", function (e) {
var item = e.target.closest(".cpg-src-item");
if (!item) return;
var code = item.getAttribute("data-code");
if (selected[code]) { delete selected[code]; item.classList.remove("is-selected"); }
else { selected[code] = true; item.classList.add("is-selected"); }
});
filter.addEventListener("input", render);
regBtn.addEventListener("click", function () {
var items = all.filter(function (it) { return selected[it.code]; })
.map(function (it) { return { code: it.code, name: it.name }; });
if (!items.length) { alert("등록할 상품을 선택하세요."); return; }
regBtn.disabled = true;
fetch("/cupang/products/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: items })
}).then(function (r) { return r.json(); })
.then(function () { location.reload(); })
.catch(function () { regBtn.disabled = false; alert("등록 실패"); });
});
fetch("/cupang/api/products/all")
.then(function (r) { return r.json(); })
.then(function (data) {
all = (data && data.results) || [];
if (!all.length) {
var msg = "itemcode_db 결과 없음";
if (data && data.error) { msg += " — 조회 오류: " + esc(data.error); }
else if (data && !data.enabled && data.reason) { msg += " — " + esc(data.reason); }
else { msg += " (테이블이 비었거나 검색 SQL 조건 불일치)"; }
listBox.innerHTML = '<p class="erp-muted">' + msg + '</p>';
return;
}
render();
})
.catch(function () { listBox.innerHTML = '<p class="erp-muted">목록 로드 실패</p>'; });
})();
</script>
{% endif %}
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
"""개인경비(expense) 모듈.
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
- 라우터: `router.py` (FastAPI APIRouter, prefix=/expense)
- 저장소: `store.py` (DATA_DIR/expense.json, 향후 expense_db 후보)
- 템플릿: `templates/expense/index.html`
"""
from pathlib import Path
from typing import Any
from .categories import DEFAULT_CATEGORIES, CategoryStore
from .router import router
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
__all__ = [
"router",
"ExpenseStore",
"CategoryStore",
"DEFAULT_CATEGORIES",
"CATEGORIES",
"METHODS",
"STATUSES",
"build_expense_store",
]
def build_expense_store(*, dsn: str | None, json_path: Path) -> Any:
"""env 의 EXPENSE_DB_URL 이 있으면 DB 저장소, 없으면 JSON 저장소.
DB 저장소 실패(드라이버 미설치/접속 실패) 시 예외를 그대로 전파한다 —
의도치 않게 JSON 으로 폴백해 운영 데이터가 갈라지는 것을 막기 위함.
"""
if dsn:
from .db import ExpenseDBStore # 지연 import (개발 환경 deps 없을 수 있음)
return ExpenseDBStore(dsn)
return ExpenseStore(json_path)
+98
View File
@@ -0,0 +1,98 @@
"""개인경비 분류(category) 설정 저장소.
- 저장 위치: DATA_DIR/expense_categories.json
- 관리자만 추가/삭제. 저장 즉시 사용자 등록 폼/집계에 반영.
- JSON 파일 기반 — expense_db(PostgreSQL) 모드와 무관하게 동작(설정값이라
트랜잭션 데이터와 분리). DB 스키마 변경(superuser SQL) 불필요.
- 동시성: ExpenseStore 와 동일 패턴(threading.Lock + temp→rename 원자적 쓰기).
"""
from __future__ import annotations
import json
import os
import tempfile
import threading
from pathlib import Path
from typing import Any
# 최초 1회 시드. 기존 하드코딩 CATEGORIES 와 동일.
DEFAULT_CATEGORIES: tuple[str, ...] = (
"식대", "교통", "숙박", "비품", "접대", "통신", "기타",
)
class CategoryStore:
def __init__(self, path: Path):
self._path = path
self._lock = threading.Lock()
self._path.parent.mkdir(parents=True, exist_ok=True)
if not self._path.exists():
self._write_atomic({"categories": list(DEFAULT_CATEGORIES)})
def _read(self) -> dict[str, Any]:
try:
with self._path.open("r", encoding="utf-8") as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
data = {}
cats = data.get("categories")
if not isinstance(cats, list) or not cats:
data["categories"] = list(DEFAULT_CATEGORIES)
else:
# 문자열만, 공백 제거, 중복 제거(순서 유지)
seen: set[str] = set()
clean: list[str] = []
for c in cats:
name = str(c).strip()
if name and name not in seen:
seen.add(name)
clean.append(name)
data["categories"] = clean or list(DEFAULT_CATEGORIES)
return data
def _write_atomic(self, data: dict[str, Any]) -> None:
fd, tmp = tempfile.mkstemp(
prefix=".expense_categories.", suffix=".json.tmp",
dir=str(self._path.parent),
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, self._path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def list(self) -> list[str]:
with self._lock:
return list(self._read()["categories"])
def add(self, name: str) -> list[str]:
name = str(name or "").strip()
if not name:
raise ValueError("분류명을 입력하세요.")
if len(name) > 30:
raise ValueError("분류명은 30자 이하로 입력하세요.")
with self._lock:
data = self._read()
if name in data["categories"]:
raise ValueError(f"이미 존재하는 분류입니다: {name}")
data["categories"].append(name)
self._write_atomic(data)
return list(data["categories"])
def delete(self, name: str) -> list[str]:
name = str(name or "").strip()
with self._lock:
data = self._read()
if name not in data["categories"]:
raise KeyError(name)
if len(data["categories"]) <= 1:
raise ValueError("분류는 최소 1개 이상이어야 합니다.")
data["categories"] = [c for c in data["categories"] if c != name]
self._write_atomic(data)
return list(data["categories"])
+534
View File
@@ -0,0 +1,534 @@
"""expense_db PostgreSQL 저장소.
JSON 저장소(`ExpenseStore`)와 같은 인터페이스를 제공하여, 라우터 코드를
바꾸지 않고도 교체할 수 있다.
- 드라이버: psycopg 3 (`psycopg[binary,pool]`)
- 연결 정보: 환경변수 `EXPENSE_DB_URL` (예: postgresql://user:pwd@host:5432/expense_db)
- 스키마: `scripts/sql/expense_db_init.sql` 로 사전 초기화한다. 본 클래스는
앱 부팅 시 `CREATE TABLE IF NOT EXISTS`로 보강만 한다.
"""
from __future__ import annotations
import uuid
from datetime import date, datetime, timezone
from typing import Any
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from app.timezone import KST
from .store import APPROVED_STATUSES, CATEGORIES, METHODS, STATUSES
class ExpenseDBStore:
"""`ExpenseStore` 와 동일한 메서드 시그니처.
스키마(테이블/인덱스/트리거)는 앱이 직접 만들지 않는다.
`scripts/sql/expense_db_init.sql` 과 `expense_db_002_*.sql` 을 통해
superuser 가 사전 적용한다. 앱 계정(expense_app)은 SELECT/INSERT/UPDATE/DELETE
권한만 받기 때문에 PostgreSQL 15+ 의 strict public-schema 정책과 충돌하지 않음.
연결 풀은 lazy open — 부팅 시점에 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
"""
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()
# ── 조회 ──
def list_for(self, email: str) -> list[dict[str, Any]]:
email = email.lower().strip()
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM expense_items WHERE owner = %s "
"ORDER BY spent_at DESC, created_at DESC",
(email,),
).fetchall()
return [self._serialize(r) for r in rows]
def list_all(self) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM expense_items ORDER BY created_at DESC"
).fetchall()
return [self._serialize(r) for r in rows]
def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None:
owner = owner.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM expense_items WHERE id = %s AND owner = %s",
(item_id, owner),
).fetchone()
return self._serialize(row) if row else None
# ── 변경 ──
def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]:
owner = owner.lower().strip()
norm = self._normalize(payload)
if not norm["spent_at"]:
raise ValueError("spent_at 필수")
status = (payload.get("status") or "작성중").strip()
item_id = uuid.uuid4().hex[:12]
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO expense_items
(id, owner, spent_at, category, method, merchant, amount, memo, status)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
item_id,
owner,
norm["spent_at"],
norm["category"],
norm["method"],
norm["merchant"],
norm["amount"],
norm["memo"],
status,
),
).fetchone()
return self._serialize(row)
def update(
self, *, item_id: str, owner: str, payload: dict[str, Any]
) -> dict[str, Any]:
owner = owner.lower().strip()
norm = self._normalize(payload)
status = payload.get("status")
with self._pool.connection() as conn:
if status:
row = conn.execute(
"""
UPDATE expense_items
SET spent_at = %s, category = %s, method = %s,
merchant = %s, amount = %s, memo = %s, status = %s
WHERE id = %s AND owner = %s
RETURNING *
""",
(
norm["spent_at"] or None,
norm["category"],
norm["method"],
norm["merchant"],
norm["amount"],
norm["memo"],
status,
item_id,
owner,
),
).fetchone()
else:
row = conn.execute(
"""
UPDATE expense_items
SET spent_at = %s, category = %s, method = %s,
merchant = %s, amount = %s, memo = %s
WHERE id = %s AND owner = %s
RETURNING *
""",
(
norm["spent_at"] or None,
norm["category"],
norm["method"],
norm["merchant"],
norm["amount"],
norm["memo"],
item_id,
owner,
),
).fetchone()
if not row:
raise KeyError(item_id)
return self._serialize(row)
def delete(self, *, item_id: str, owner: str) -> None:
owner = owner.lower().strip()
with self._pool.connection() as conn:
cur = conn.execute(
"DELETE FROM expense_items WHERE id = %s AND owner = %s",
(item_id, owner),
)
if cur.rowcount == 0:
raise KeyError(item_id)
# ── 워크플로 ──
def submit(self, *, item_id: str, owner: str) -> dict[str, Any]:
return self._transition_owner(
item_id=item_id, owner=owner, from_status="작성중", to_status="제출"
)
def revert_to_draft(self, *, item_id: str, owner: str) -> dict[str, Any]:
"""반려 또는 제출 상태에서 본인이 작성중으로 되돌림."""
owner = owner.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"""
UPDATE expense_items
SET status = '작성중',
reject_reason = NULL,
decided_at = NULL,
approver_email = NULL
WHERE id = %s AND owner = %s
AND status IN ('제출', '반려')
RETURNING *
""",
(item_id, owner),
).fetchone()
if not row:
raise ValueError("작성중 으로 되돌릴 수 없는 상태입니다.")
return self._serialize(row)
def approve(self, *, item_id: str, approver_email: str) -> dict[str, Any]:
return self._transition_approver(
item_id=item_id,
approver_email=approver_email,
from_statuses=("제출",),
to_status="승인",
)
def reject(
self, *, item_id: str, approver_email: str, reason: str
) -> dict[str, Any]:
if not reason.strip():
raise ValueError("반려 사유 필수")
approver_email = approver_email.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"""
UPDATE expense_items
SET status = '반려',
approver_email = %s,
decided_at = now(),
reject_reason = %s
WHERE id = %s AND status = '제출'
RETURNING *
""",
(approver_email, reason.strip(), item_id),
).fetchone()
if not row:
raise ValueError("제출 상태가 아니거나 항목 없음")
return self._serialize(row)
def settle(self, *, item_id: str, approver_email: str) -> dict[str, Any]:
return self._transition_approver(
item_id=item_id,
approver_email=approver_email,
from_statuses=("승인",),
to_status="정산완료",
)
def _transition_owner(
self, *, item_id: str, owner: str, from_status: str, to_status: str
) -> dict[str, Any]:
owner = owner.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"""
UPDATE expense_items
SET status = %s
WHERE id = %s AND owner = %s AND status = %s
RETURNING *
""",
(to_status, item_id, owner, from_status),
).fetchone()
if not row:
raise ValueError(f"전이 불가: {from_status}{to_status}")
return self._serialize(row)
def _transition_approver(
self,
*,
item_id: str,
approver_email: str,
from_statuses: tuple[str, ...],
to_status: str,
) -> dict[str, Any]:
approver_email = approver_email.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"""
UPDATE expense_items
SET status = %s,
approver_email = %s,
decided_at = now()
WHERE id = %s AND status = ANY(%s)
RETURNING *
""",
(to_status, approver_email, item_id, list(from_statuses)),
).fetchone()
if not row:
raise ValueError(f"전이 불가 → {to_status}")
return self._serialize(row)
# ── 승인자 대기열 ──
def list_pending_approval(self) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM expense_items WHERE status = '제출' "
"ORDER BY created_at ASC"
).fetchall()
return [self._serialize(r) for r in rows]
def get_any(self, *, item_id: str) -> dict[str, Any] | None:
"""승인자/관리자용 — owner 무시하고 단일 조회."""
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM expense_items WHERE id = %s", (item_id,)
).fetchone()
return self._serialize(row) if row else None
# ── 첨부 ──
def add_attachment(
self,
*,
item_id: str,
owner: str,
kind: str,
filename: str,
stored_path: str,
content_type: str,
size_bytes: int,
) -> dict[str, Any]:
if kind not in ("receipt", "other"):
raise ValueError("kind 는 receipt|other")
owner = owner.lower().strip()
att_id = uuid.uuid4().hex[:12]
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO expense_attachments
(id, item_id, owner, kind, filename, stored_path,
content_type, size_bytes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
att_id,
item_id,
owner,
kind,
filename,
stored_path,
content_type,
size_bytes,
),
).fetchone()
return self._att_serialize(row)
def list_attachments(self, *, item_id: str) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM expense_attachments WHERE item_id = %s "
"ORDER BY uploaded_at ASC",
(item_id,),
).fetchall()
return [self._att_serialize(r) for r in rows]
def get_attachment(self, *, att_id: str) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM expense_attachments WHERE id = %s", (att_id,)
).fetchone()
return self._att_serialize(row) if row else None
def delete_attachment(self, *, att_id: str, owner: str) -> dict[str, Any]:
"""삭제된 행 반환 (파일 정리용 stored_path 포함)."""
owner = owner.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"DELETE FROM expense_attachments "
"WHERE id = %s AND owner = %s RETURNING *",
(att_id, owner),
).fetchone()
if not row:
raise KeyError(att_id)
return self._att_serialize(row)
@staticmethod
def _att_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
v = out.get("uploaded_at")
if isinstance(v, datetime):
out["uploaded_at"] = v.astimezone(KST).isoformat(timespec="seconds")
out["size_bytes"] = int(out.get("size_bytes", 0))
return out
# ── 승인완료 (월별, 전 직원) ──
def list_approved(self, *, year: int, month: int) -> list[dict[str, Any]]:
"""해당 월(spent_at)의 승인완료 항목 — 전 직원."""
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT * FROM expense_items
WHERE status = ANY(%s)
AND EXTRACT(YEAR FROM spent_at) = %s
AND EXTRACT(MONTH FROM spent_at) = %s
ORDER BY owner ASC, spent_at ASC, created_at ASC
""",
(list(APPROVED_STATUSES), year, month),
).fetchall()
return [self._serialize(r) for r in rows]
def approved_attachments(
self, *, year: int, month: int
) -> list[dict[str, Any]]:
"""해당 월 승인완료 항목의 첨부 — zip 다운로드용.
uploaded_at(datetime), spent_at(date) 를 가공 없이 반환(파일명 생성용).
"""
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT a.id, a.item_id, a.owner, a.kind, a.filename,
a.stored_path, a.content_type, a.uploaded_at,
i.spent_at, i.category, i.method, i.amount, i.merchant
FROM expense_attachments a
JOIN expense_items i ON i.id = a.item_id
WHERE i.status = ANY(%s)
AND EXTRACT(YEAR FROM i.spent_at) = %s
AND EXTRACT(MONTH FROM i.spent_at) = %s
ORDER BY a.owner ASC, a.uploaded_at ASC
""",
(list(APPROVED_STATUSES), year, month),
).fetchall()
return [dict(r) for r in rows]
# ── 집계 (월별) ──
def monthly_summary(
self, *, email: str, year: int
) -> list[dict[str, Any]]:
email = email.lower().strip()
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT to_char(spent_at, 'YYYY-MM') AS month,
category,
COUNT(*) AS cnt,
COALESCE(SUM(amount), 0) AS total
FROM expense_items
WHERE owner = %s AND EXTRACT(YEAR FROM spent_at) = %s
GROUP BY 1, 2
ORDER BY 1, 2
""",
(email, year),
).fetchall()
return [
{
"month": r["month"],
"category": r["category"],
"count": int(r["cnt"]),
"total": int(r["total"]),
}
for r in rows
]
def list_for_export(
self,
*,
email: str | None,
date_from: str | None = None,
date_to: str | None = None,
) -> list[dict[str, Any]]:
"""엑셀 내보내기용. email=None 이면 전체 (승인자/관리자용)."""
clauses = []
params: list[Any] = []
if email:
clauses.append("owner = %s")
params.append(email.lower().strip())
if date_from:
clauses.append("spent_at >= %s")
params.append(date_from)
if date_to:
clauses.append("spent_at <= %s")
params.append(date_to)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM expense_items {where} "
f"ORDER BY spent_at ASC, created_at ASC",
params,
).fetchall()
return [self._serialize(r) for r in rows]
# ── 요약 ──
def summary_for(self, email: str) -> dict[str, Any]:
email = email.lower().strip()
with self._pool.connection() as conn:
head = conn.execute(
"SELECT COUNT(*) AS count, COALESCE(SUM(amount), 0) AS total "
"FROM expense_items WHERE owner = %s",
(email,),
).fetchone()
status_rows = conn.execute(
"SELECT status, COUNT(*) AS c FROM expense_items "
"WHERE owner = %s GROUP BY status",
(email,),
).fetchall()
cat_rows = conn.execute(
"SELECT category, COALESCE(SUM(amount), 0) AS s "
"FROM expense_items WHERE owner = %s GROUP BY category",
(email,),
).fetchall()
by_status = {s: 0 for s in STATUSES}
for r in status_rows:
by_status[r["status"]] = int(r["c"])
by_category = {c: 0 for c in CATEGORIES}
for r in cat_rows:
by_category[r["category"]] = int(r["s"])
return {
"count": int(head["count"]) if head else 0,
"total": int(head["total"]) if head else 0,
"by_status": by_status,
"by_category": by_category,
}
# ── helpers ──
@staticmethod
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
if isinstance(out.get("spent_at"), date):
out["spent_at"] = out["spent_at"].isoformat()
for k in ("created_at", "updated_at", "decided_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
out["amount"] = int(out.get("amount", 0))
return out
@staticmethod
def _normalize(
payload: dict[str, Any], base: dict[str, Any] | None = None
) -> dict[str, Any]:
b = dict(base or {})
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
category = str(payload.get("category") or b.get("category") or "기타").strip()
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
b["category"] = category or "기타"
b["method"] = method if method in METHODS else "법인카드"
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
try:
b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0))
except (TypeError, ValueError):
b["amount"] = 0
b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip()
return b
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
"""개인경비 항목 JSON 저장소.
- 저장 위치: DATA_DIR/expense.json
- 사용자(email) 단위 소유. 본인 항목만 조회/수정/삭제.
- 향후 expense_db(PostgreSQL)로 마이그레이션 예정. 현재는 DB 생성 승인 전이라 JSON 사용.
- 동시성: 프로세스 내 threading.Lock + 원자적 쓰기(temp → rename). UserStore 와 동일 패턴.
"""
from __future__ import annotations
import json
import os
import tempfile
import threading
import uuid
from pathlib import Path
from typing import Any
from app.timezone import now_kst_iso
CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타")
METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금")
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료")
# 승인 완료(결재 승인 이후) 상태 — "승인완료" 집계/내보내기 대상.
APPROVED_STATUSES: tuple[str, ...] = ("승인", "정산완료")
def _now_iso() -> str:
return now_kst_iso()
class ExpenseStore:
def __init__(self, path: Path):
self._path = path
self._lock = threading.Lock()
self._path.parent.mkdir(parents=True, exist_ok=True)
if not self._path.exists():
self._write_atomic({"items": []})
def _read(self) -> dict[str, Any]:
try:
with self._path.open("r", encoding="utf-8") as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
data = {"items": []}
if not isinstance(data.get("items"), list):
data["items"] = []
return data
def _write_atomic(self, data: dict[str, Any]) -> None:
fd, tmp = tempfile.mkstemp(
prefix=".expense.", suffix=".json.tmp", dir=str(self._path.parent)
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, self._path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def list_for(self, email: str) -> list[dict[str, Any]]:
email = email.lower().strip()
with self._lock:
data = self._read()
return [it for it in data["items"] if it.get("owner") == email]
def list_all(self) -> list[dict[str, Any]]:
with self._lock:
return list(self._read()["items"])
def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None:
owner = owner.lower().strip()
with self._lock:
data = self._read()
for it in data["items"]:
if it["id"] == item_id and it.get("owner") == owner:
return dict(it)
return None
def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]:
owner = owner.lower().strip()
item = self._normalize(payload)
item["id"] = uuid.uuid4().hex[:12]
item["owner"] = owner
item["status"] = payload.get("status") or "작성중"
item["created_at"] = _now_iso()
item["updated_at"] = item["created_at"]
with self._lock:
data = self._read()
data["items"].append(item)
self._write_atomic(data)
return item
def update(
self, *, item_id: str, owner: str, payload: dict[str, Any]
) -> dict[str, Any]:
owner = owner.lower().strip()
with self._lock:
data = self._read()
for idx, it in enumerate(data["items"]):
if it["id"] == item_id and it.get("owner") == owner:
new = self._normalize(payload, base=it)
new["id"] = it["id"]
new["owner"] = it["owner"]
new["created_at"] = it.get("created_at", _now_iso())
new["status"] = payload.get("status") or it.get("status", "작성중")
new["updated_at"] = _now_iso()
data["items"][idx] = new
self._write_atomic(data)
return new
raise KeyError(item_id)
def delete(self, *, item_id: str, owner: str) -> None:
owner = owner.lower().strip()
with self._lock:
data = self._read()
before = len(data["items"])
data["items"] = [
it
for it in data["items"]
if not (it["id"] == item_id and it.get("owner") == owner)
]
if len(data["items"]) == before:
raise KeyError(item_id)
self._write_atomic(data)
def summary_for(self, email: str) -> dict[str, Any]:
items = self.list_for(email)
total = sum(int(i.get("amount", 0)) for i in items)
by_status = {s: sum(1 for i in items if i.get("status") == s) for s in STATUSES}
by_category = {
c: sum(int(i.get("amount", 0)) for i in items if i.get("category") == c)
for c in CATEGORIES
}
return {
"count": len(items),
"total": total,
"by_status": by_status,
"by_category": by_category,
}
@staticmethod
def _normalize(
payload: dict[str, Any], base: dict[str, Any] | None = None
) -> dict[str, Any]:
b = dict(base or {})
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
category = str(payload.get("category") or b.get("category") or "기타").strip()
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
b["category"] = category or "기타"
b["method"] = method if method in METHODS else "법인카드"
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
try:
b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0))
except (TypeError, ValueError):
b["amount"] = 0
b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip()
return b
@@ -0,0 +1,138 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="erp-ex-approved">
<!-- ── 상단 액션 + 월 선택 ── -->
<div class="erp-page-actions">
<a class="erp-btn erp-btn-ghost" href="/expense/">← 개인경비</a>
<form id="mon-form" method="get" action="/expense/approved" style="display:flex; gap:var(--sp-8); align-items:center; margin:0;">
<input type="month" name="month" value="{{ month }}" />
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
</form>
<a class="erp-btn erp-btn-primary" href="/expense/api/approved/attachments.zip?month={{ month }}">전부 다운로드(zip)</a>
</div>
<!-- ── 직원별 합계 ── -->
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>직원별 승인 금액 합계</h2>
<span class="erp-muted">{{ month }} · 총 {{ "{:,}".format(grand_total) }} 원</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr>
<th>이름</th>
<th>이메일</th>
<th style="width:100px; text-align:right;">건수</th>
<th style="width:160px; text-align:right;">합계금액</th>
</tr>
</thead>
<tbody>
{% for r in owner_summary %}
<tr>
<td>{{ r.name }}</td>
<td class="erp-muted">{{ r.owner }}</td>
<td style="text-align:right;">{{ r.count }}</td>
<td style="text-align:right; font-variant-numeric: tabular-nums;">{{ "{:,}".format(r.total) }} 원</td>
</tr>
{% else %}
<tr><td colspan="4" class="erp-empty">해당 월 승인완료 항목이 없습니다.</td></tr>
{% endfor %}
</tbody>
{% if owner_summary %}
<tfoot>
<tr>
<th colspan="2" style="text-align:right;">합계</th>
<th style="text-align:right;">{{ count }}</th>
<th style="text-align:right;">{{ "{:,}".format(grand_total) }} 원</th>
</tr>
</tfoot>
{% endif %}
</table>
</div>
</div>
<!-- ── 승인완료 항목 목록 ── -->
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>승인완료 항목 ({{ count }}건)</h2>
<span class="erp-muted">전 직원 · 사용일 기준 {{ month }}</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table" id="appr-table">
<thead>
<tr>
<th style="width:110px;">사용일</th>
<th style="width:120px;">이름</th>
<th style="width:90px;">분류</th>
<th style="width:100px;">수단</th>
<th>가맹점/메모</th>
<th style="width:120px; text-align:right;">금액</th>
<th style="width:90px;">상태</th>
<th style="width:60px; text-align:center;">첨부</th>
</tr>
</thead>
<tbody>
{% for it in items %}
<tr data-id="{{ it.id }}">
<td>{{ it.spent_at }}</td>
<td>{{ it.owner_name }}<div class="erp-row-sub">{{ it.owner }}</div></td>
<td>{{ it.category }}</td>
<td>{{ it.method }}</td>
<td>
<div>{{ it.merchant }}</div>
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
</td>
<td style="text-align:right; font-variant-numeric: tabular-nums;">{{ "{:,}".format(it.amount) }} 원</td>
<td>
{% if it.status == '정산완료' %}
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
{% else %}
<span class="erp-badge erp-badge-inverse">{{ it.status }}</span>
{% endif %}
</td>
<td style="text-align:center;">
<button class="erp-btn erp-btn-ghost erp-btn-sm js-view-att" title="첨부 보기">📎</button>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="erp-empty">해당 월 승인완료 항목이 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
<style>
.erp-page-actions { display:flex; gap:var(--sp-8); margin-bottom:var(--sp-16); flex-wrap:wrap; align-items:center; }
.erp-ex-approved .erp-row-sub { font-size:12px; color:var(--color-text-muted, #888); }
#appr-table td { vertical-align: middle; }
</style>
{% endblock %}
{% block scripts %}
<script>
(function () {
// 월 선택 변경 시 자동 조회
const monInput = document.querySelector('#mon-form input[name="month"]');
if (monInput) monInput.addEventListener("change", () => monInput.form.submit());
// 첨부 보기 (공용 뷰어)
const tbody = document.querySelector("#appr-table tbody");
if (tbody) {
tbody.addEventListener("click", (e) => {
const btn = e.target.closest("button.js-view-att");
if (!btn) return;
const tr = btn.closest("tr[data-id]");
const id = tr.dataset.id;
const who = tr.children[1].textContent.trim();
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${who}` });
});
}
})();
</script>
{% endblock %}
@@ -0,0 +1,418 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="erp-expense">
<!-- ── 페이지 액션 ── -->
<div class="erp-page-actions">
{% if is_approver %}
<a class="erp-btn erp-btn-outline" href="/expense/pending">
승인 대기 {% if pending_count %}<strong style="margin-left:6px;">{{ pending_count }}</strong>{% endif %}
</a>
<a class="erp-btn erp-btn-outline" href="/expense/approved">승인완료</a>
{% endif %}
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=mine">엑셀(내 항목)</a>
{% if is_approver %}
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
{% endif %}
{% if is_approver %}
<a class="erp-btn erp-btn-outline" href="/expense/settings">⚙ 설정</a>
{% endif %}
</div>
<!-- ── 요약 카드 ── -->
<div class="erp-summary-grid">
{% for status in statuses %}
<div class="erp-summary-card erp-summary-card--mini">
<span class="erp-summary-label">{{ status }}</span>
<strong class="erp-summary-value erp-summary-value--sm">
{{ summary.by_status.get(status, 0) }}
</strong>
</div>
{% endfor %}
</div>
<!-- ── 등록(좌) / 내역(우) 2단 ── -->
<div class="ex-two-col">
<!-- ── 입력 폼 ── -->
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>경비 등록</h2>
<span class="erp-muted">필수 항목 입력 후 등록. 등록 후 항목별로 첨부/제출.</span>
</div>
<form id="ex-form" class="erp-form-grid">
<input type="hidden" name="id" />
<label class="erp-field"><span>사용일</span><input type="date" name="spent_at" required /></label>
<label class="erp-field"><span>분류</span>
<select name="category" required>
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
</select>
</label>
<label class="erp-field"><span>결제수단</span>
<select name="method" required>
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
</select>
</label>
<label class="erp-field"><span>금액</span>
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
</label>
<label class="erp-field erp-field-wide"><span>가맹점/사용처</span>
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" required />
</label>
<label class="erp-field erp-field-wide"><span>메모</span>
<input type="text" name="memo" placeholder="비고" />
</label>
<div class="erp-form-actions">
<button type="reset" class="erp-btn erp-btn-outline" id="ex-reset">초기화</button>
<button type="submit" class="erp-btn erp-btn-primary" id="ex-submit">등록</button>
</div>
</form>
</div>
<!-- ── 항목 목록 ── -->
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>경비 내역</h2>
<div style="display: flex; align-items: center; gap: var(--sp-12); flex-wrap: wrap;">
<form method="get" action="/expense/" id="ex-month-form" style="display:flex; gap:var(--sp-8); align-items:center; margin:0;">
<input type="month" name="month" value="{{ month }}" />
</form>
<span class="ex-month-stat" id="ex-count">{{ items | length }}건</span>
<span class="ex-month-stat">합계 <strong>{{ "{:,}".format(month_total) }}</strong></span>
{% if supports_workflow %}
<button id="ex-bulk-submit" class="erp-btn erp-btn-primary erp-btn-sm" disabled>
선택 항목 제출 (<span id="ex-bulk-count">0</span>)
</button>
{% endif %}
</div>
</div>
<div class="erp-table-wrap">
<table class="erp-table" id="ex-table">
<thead>
<tr>
<th style="width: 36px; text-align: center;">
<input type="checkbox" id="ex-select-all" title="전체 선택" />
</th>
<th style="width: 110px;">사용일</th>
<th style="width: 90px;">분류</th>
<th style="width: 100px;">수단</th>
<th>가맹점</th>
<th style="width: 120px;">금액</th>
<th style="width: 90px;">상태</th>
<th style="width: 240px;"></th>
</tr>
</thead>
<tbody id="ex-tbody">
{% for it in items %}
<tr data-id="{{ it.id }}" data-status="{{ it.status }}">
<td style="text-align: center;">
{% if it.status in ('작성중', '반려') and supports_workflow %}
<input type="checkbox" class="js-select" />
{% endif %}
</td>
<td>{{ it.spent_at }}</td>
<td>{{ it.category }}</td>
<td>{{ it.method }}</td>
<td>
<div>{{ it.merchant }}</div>
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
{% if it.reject_reason %}
<div class="erp-row-sub" style="color: var(--color-callout-red);">반려: {{ it.reject_reason }}</div>
{% endif %}
</td>
<td style="text-align: right; font-variant-numeric: tabular-nums;">
{{ "{:,}".format(it.amount) }} 원
</td>
<td>
{% if it.status == '작성중' or it.status == '반려' %}
<span class="erp-badge erp-badge-outline">{{ it.status }}</span>
{% elif it.status == '제출' %}
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
{% elif it.status == '승인' %}
<span class="erp-badge erp-badge-inverse">{{ it.status }}</span>
{% else %}
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
{% endif %}
</td>
<td style="text-align: right;" class="js-actions">
{% if it.status in ('작성중', '반려') and supports_workflow %}
<label class="erp-btn erp-btn-outline erp-btn-sm">
영수증<input type="file" class="js-upload" data-kind="receipt" hidden />
</label>
<label class="erp-btn erp-btn-outline erp-btn-sm">
기타 파일<input type="file" class="js-upload" data-kind="other" hidden />
</label>
{% endif %}
{% if it.status in ('작성중', '반려') %}
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
{% elif it.status == '제출' and supports_workflow %}
<button class="erp-btn erp-btn-ghost erp-btn-sm js-revert">취소(작성중)</button>
{% endif %}
<button class="erp-btn erp-btn-ghost erp-btn-sm js-view-att" title="첨부 보기">📎</button>
</td>
</tr>
{% else %}
<tr id="ex-empty"><td colspan="8" class="erp-empty">등록된 경비가 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div><!-- /ex-two-col -->
</section>
<style>
.erp-page-actions {
display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); flex-wrap: wrap;
}
#ex-tbody td { vertical-align: middle; }
/* 경비 내역 월 통계(건수/합계) 강조 */
.ex-month-stat { font-size: 18px; font-weight: 600; color: var(--color-text, #1a1a1a); }
.ex-month-stat strong { font-size: 20px; }
#ex-tbody .js-actions { white-space: nowrap; }
#ex-tbody .js-actions > * { margin-left: 4px; vertical-align: middle; }
/* 상단 요약 카드: 세로 70px 고정 (grid 행 높이 고정 → stretch 무력화) */
.erp-expense .erp-summary-grid {
grid-auto-rows: 70px !important;
margin-bottom: var(--sp-20); /* 등록 폼과 간격 */
}
.erp-expense .erp-summary-card {
height: 70px !important; min-height: 0 !important; max-height: 70px;
padding: 8px 14px; justify-content: center; gap: 2px; overflow: hidden;
}
.erp-expense .erp-summary-card--mini { padding: 8px 14px; }
/* 요약줄과 2단 블록 사이 간격 */
.ex-two-col { margin-top: var(--sp-20); }
/* 경비 내역 테이블: 전부 가운데 정렬 + 한 줄(2줄 방지) + 너비 자동 */
#ex-table { table-layout: auto; }
#ex-table th, #ex-table td {
text-align: center !important;
white-space: nowrap;
width: auto !important;
vertical-align: middle;
}
#ex-table th:first-child, #ex-table td:first-child { width: 36px !important; }
#ex-table td > div { white-space: nowrap; } /* 가맹점/메모 줄바꿈 방지 */
#ex-tbody .js-actions { text-align: center !important; }
#ex-tbody .js-actions > * { margin: 0 2px; }
/* 첫 행(헤더) 모서리 라운드 제거 — 라운드는 .erp-table-wrap 에 걸려 있음 */
.erp-expense .erp-table-wrap { border-radius: 0 !important; box-shadow: none; }
/* 첨부(클립) 아이콘 크게 */
#ex-tbody .js-view-att { font-size: 22px !important; line-height: 1; padding: 2px 8px; }
.erp-expense .erp-summary-value { font-size: 20px; line-height: 1.1; }
.erp-expense .erp-summary-value--sm { font-size: 18px; }
/* 경비 등록(좌) / 경비 내역(우) 2단 */
.ex-two-col {
display: grid; grid-template-columns: 520px minmax(0, 1fr);
gap: var(--sp-16); align-items: start;
}
.ex-two-col > .erp-card-block { margin: 0; }
/* 좌측 폼은 2열로 (가맹점/메모는 전체폭) */
.ex-two-col .erp-form-grid { grid-template-columns: 1fr 1fr; }
.ex-two-col .erp-form-grid .erp-field-wide,
.ex-two-col .erp-form-grid .erp-form-actions { grid-column: 1 / -1; }
@media (max-width: 1100px) {
.ex-two-col { grid-template-columns: 1fr; }
}
</style>
{% endblock %}
{% block scripts %}
<script>
(function () {
const supportsWorkflow = {{ 'true' if supports_workflow else 'false' }};
const form = document.getElementById("ex-form");
const submitBtn = document.getElementById("ex-submit");
const resetBtn = document.getElementById("ex-reset");
const tbody = document.getElementById("ex-tbody");
const selectAll = document.getElementById("ex-select-all");
const bulkBtn = document.getElementById("ex-bulk-submit");
const bulkCountEl = document.getElementById("ex-bulk-count");
function setEditing(id, data) {
form.id.value = id || "";
if (data) {
form.spent_at.value = data.spent_at || "";
form.category.value = data.category || "";
form.method.value = data.method || "";
form.amount.value = data.amount || 0;
form.merchant.value = data.merchant || "";
form.memo.value = data.memo || "";
submitBtn.textContent = "수정 저장";
} else {
submitBtn.textContent = "등록";
}
}
resetBtn.addEventListener("click", () => setEditing("", null));
// 필수 입력 검증 — 누락 시 경고창
function validateRequired() {
const required = [
["spent_at", "사용일"],
["category", "분류"],
["method", "결제수단"],
["amount", "금액"],
["merchant", "가맹점/사용처"],
];
const missing = [];
for (const [field, label] of required) {
const val = (form[field].value || "").trim();
if (!val) missing.push([form[field], label]);
}
// 금액은 0 이하도 미입력 취급
if (!missing.some(([f]) => f === form.amount)) {
if (!(parseInt(form.amount.value, 10) > 0)) {
missing.push([form.amount, "금액(1원 이상)"]);
}
}
if (missing.length) {
alert("다음 항목을 입력하세요:\n- " + missing.map(([, l]) => l).join("\n- "));
missing[0][0].focus();
return false;
}
return true;
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!validateRequired()) return;
const id = form.id.value.trim();
const payload = {
spent_at: form.spent_at.value,
category: form.category.value,
method: form.method.value,
amount: parseInt(form.amount.value || "0", 10),
merchant: form.merchant.value,
memo: form.memo.value,
};
const url = id ? `/expense/api/items/${id}` : "/expense/api/items";
const method = id ? "PUT" : "POST";
try {
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error((await res.json()).detail || res.status);
location.reload();
} catch (err) {
alert(`저장 실패: ${err.message || err}`);
}
});
// 체크박스 상태 갱신
function refreshSelection() {
if (!bulkBtn) return;
const cbs = tbody.querySelectorAll("input.js-select");
const checked = Array.from(cbs).filter((cb) => cb.checked);
bulkBtn.disabled = checked.length === 0;
if (bulkCountEl) bulkCountEl.textContent = checked.length;
if (selectAll && cbs.length > 0) {
selectAll.checked = checked.length === cbs.length;
selectAll.indeterminate = checked.length > 0 && checked.length < cbs.length;
}
}
if (selectAll) {
selectAll.addEventListener("change", () => {
tbody.querySelectorAll("input.js-select").forEach((cb) => {
cb.checked = selectAll.checked;
});
refreshSelection();
});
}
tbody.addEventListener("change", (e) => {
if (e.target.classList.contains("js-select")) refreshSelection();
});
if (bulkBtn) {
bulkBtn.addEventListener("click", async () => {
const ids = Array.from(tbody.querySelectorAll("input.js-select:checked"))
.map((cb) => cb.closest("tr").dataset.id);
if (!ids.length) return;
if (!confirm(`${ids.length}건을 결재 제출할까요? 제출 후에는 수정 불가.`)) return;
bulkBtn.disabled = true;
bulkBtn.textContent = "제출 중…";
const fails = [];
for (const id of ids) {
try {
const res = await fetch(`/expense/api/items/${id}/submit`, { method: "POST" });
if (!res.ok) fails.push(`${id}: ${(await res.json()).detail || res.status}`);
} catch (err) { fails.push(`${id}: ${err.message || err}`); }
}
if (fails.length) alert("일부 실패:\n" + fails.join("\n"));
location.reload();
});
}
// 행 동작 (수정/삭제/취소/첨부보기 + 업로드)
tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const tr = btn.closest("tr[data-id]");
if (!tr) return;
const id = tr.dataset.id;
if (btn.classList.contains("js-edit")) {
const res = await fetch(`/expense/api/items`);
if (!res.ok) return;
const { items } = await res.json();
const found = items.find((x) => x.id === id);
if (found) setEditing(id, found);
window.scrollTo({ top: 0, behavior: "smooth" });
} else if (btn.classList.contains("js-delete")) {
if (!confirm("이 항목을 삭제할까요? 첨부도 함께 삭제됩니다.")) return;
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
if (res.ok) location.reload(); else alert((await res.json()).detail || "삭제 실패");
} else if (btn.classList.contains("js-revert")) {
if (!confirm("작성중 상태로 되돌릴까요?")) return;
const res = await fetch(`/expense/api/items/${id}/revert`, { method: "POST" });
if (res.ok) location.reload(); else alert((await res.json()).detail || "취소 실패");
} else if (btn.classList.contains("js-view-att")) {
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${tr.children[1].textContent.trim()}` });
}
});
// 첨부 업로드
tbody.addEventListener("change", async (e) => {
const input = e.target.closest("input.js-upload");
if (!input || !input.files.length) return;
const tr = input.closest("tr[data-id]");
const itemId = tr.dataset.id;
const fd = new FormData();
fd.append("file", input.files[0]);
fd.append("kind", input.dataset.kind || "other");
try {
const res = await fetch(`/expense/api/items/${itemId}/attachments`, {
method: "POST", body: fd,
});
if (!res.ok) throw new Error((await res.json()).detail || res.status);
input.value = "";
alert("첨부 업로드 완료");
} catch (err) {
alert(`업로드 실패: ${err.message || err}`);
}
});
if (!form.spent_at.value) {
const d = new Date();
form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
}
// 월 선택 변경 시 자동 조회
const monthInput = document.querySelector('#ex-month-form input[name="month"]');
if (monthInput) monthInput.addEventListener("change", () => monthInput.form.submit());
})();
</script>
{% endblock %}
@@ -0,0 +1,152 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="erp-pending">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-ghost" href="/expense/">← 내 개인경비</a>
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
</div>
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>승인 대기 ({{ items | length }}건)</h2>
<div style="display: flex; align-items: center; gap: var(--sp-12);">
<span class="erp-muted">제출 상태 항목 — 일괄 승인 / 항목별 반려</span>
<button id="pend-bulk-approve" class="erp-btn erp-btn-primary erp-btn-sm" disabled>
선택 일괄 승인 (<span id="pend-bulk-count">0</span>)
</button>
</div>
</div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr>
<th style="width: 36px; text-align: center;">
<input type="checkbox" id="pend-select-all" title="전체 선택" />
</th>
<th style="width: 110px;">사용일</th>
<th style="width: 200px;">소유자</th>
<th style="width: 90px;">분류</th>
<th>가맹점/메모</th>
<th style="width: 120px; text-align: right;">금액</th>
<th style="width: 200px; text-align: right;">동작</th>
</tr>
</thead>
<tbody id="pend-tbody">
{% for it in items %}
<tr data-id="{{ it.id }}">
<td style="text-align: center;">
<input type="checkbox" class="js-select" />
</td>
<td>{{ it.spent_at }}</td>
<td>{{ it.owner }}</td>
<td>{{ it.category }}</td>
<td>
<div>{{ it.merchant }}</div>
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
</td>
<td style="text-align: right; font-variant-numeric: tabular-nums;">
{{ "{:,}".format(it.amount) }} 원
</td>
<td style="text-align: right;">
<button class="erp-btn erp-btn-ghost erp-btn-sm js-attach" title="첨부 보기" aria-label="첨부 보기">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66L9.41 17.41a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<button class="erp-btn erp-btn-outline erp-btn-sm js-reject">반려</button>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="erp-empty">대기 항목이 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
<style>
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
#pend-tbody td { vertical-align: middle; }
</style>
{% endblock %}
{% block scripts %}
<script>
(function () {
const tbody = document.getElementById("pend-tbody");
const selectAll = document.getElementById("pend-select-all");
const bulkBtn = document.getElementById("pend-bulk-approve");
const bulkCountEl = document.getElementById("pend-bulk-count");
function refreshSelection() {
const cbs = tbody.querySelectorAll("input.js-select");
const checked = Array.from(cbs).filter((cb) => cb.checked);
bulkBtn.disabled = checked.length === 0;
bulkCountEl.textContent = checked.length;
if (cbs.length > 0) {
selectAll.checked = checked.length === cbs.length;
selectAll.indeterminate = checked.length > 0 && checked.length < cbs.length;
}
}
selectAll.addEventListener("change", () => {
tbody.querySelectorAll("input.js-select").forEach((cb) => {
cb.checked = selectAll.checked;
});
refreshSelection();
});
tbody.addEventListener("change", (e) => {
if (e.target.classList.contains("js-select")) refreshSelection();
});
bulkBtn.addEventListener("click", async () => {
const ids = Array.from(tbody.querySelectorAll("input.js-select:checked"))
.map((cb) => cb.closest("tr").dataset.id);
if (!ids.length) return;
if (!confirm(`${ids.length}건을 일괄 승인할까요?`)) return;
bulkBtn.disabled = true;
bulkBtn.textContent = "승인 중…";
const fails = [];
for (const id of ids) {
try {
const res = await fetch(`/expense/api/items/${id}/approve`, { method: "POST" });
if (!res.ok) fails.push(`${id}: ${(await res.json()).detail || res.status}`);
} catch (err) { fails.push(`${id}: ${err.message || err}`); }
}
if (fails.length) alert("일부 실패:\n" + fails.join("\n"));
location.reload();
});
tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const tr = btn.closest("tr[data-id]");
const id = tr.dataset.id;
if (btn.classList.contains("js-attach")) {
const owner = tr.children[2]?.textContent?.trim() || "";
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${owner}` });
return;
}
if (btn.classList.contains("js-reject")) {
const reason = prompt("반려 사유를 입력하세요:");
if (!reason || !reason.trim()) return;
const res = await fetch(`/expense/api/items/${id}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: reason.trim() }),
});
if (res.ok) { tr.remove(); refreshSelection(); }
else { alert((await res.json()).detail || "반려 실패"); }
}
});
})();
</script>
{% endblock %}
@@ -0,0 +1,78 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="erp-reports">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-ghost" href="/expense/">← 내 개인경비</a>
<form method="get" style="display: inline-flex; gap: 6px; align-items: center;">
<label class="erp-muted">연도</label>
<input type="number" name="year" value="{{ year }}" min="2000" max="2100"
style="width: 100px; padding: 6px 10px; border: 1px solid var(--color-subtle-ash);
border-radius: var(--r-input); font-family: inherit;" />
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
</form>
<a class="erp-btn erp-btn-outline"
href="/expense/api/export.xlsx?from={{ year }}-01-01&to={{ year }}-12-31">엑셀</a>
</div>
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>{{ year }}년 월별 / 카테고리별 합계</h2>
<span class="erp-muted">총 {{ "{:,}".format(grand_total) }} 원</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr>
<th style="width: 110px;"></th>
{% for c in categories %}
<th style="text-align: right;">{{ c }}</th>
{% endfor %}
<th style="text-align: right; background: var(--color-ghost-gray);">합계</th>
</tr>
</thead>
<tbody>
{% for m in months %}
{% set row_total = pivot[m].values() | sum %}
<tr>
<td><strong>{{ m }}</strong></td>
{% for c in categories %}
<td style="text-align: right; font-variant-numeric: tabular-nums;">
{% if pivot[m].get(c) %}{{ "{:,}".format(pivot[m][c]) }}{% else %}-{% endif %}
</td>
{% endfor %}
<td style="text-align: right; font-variant-numeric: tabular-nums; background: var(--color-ghost-gray);">
<strong>{{ "{:,}".format(row_total) }}</strong>
</td>
</tr>
{% else %}
<tr><td colspan="{{ categories | length + 2 }}" class="erp-empty">데이터 없음</td></tr>
{% endfor %}
</tbody>
{% if months %}
<tfoot>
<tr style="background: var(--color-ghost-gray);">
<th>합계</th>
{% for c in categories %}
<th style="text-align: right; font-variant-numeric: tabular-nums;">
{{ "{:,}".format(cat_totals[c]) }}
</th>
{% endfor %}
<th style="text-align: right; font-variant-numeric: tabular-nums;">
{{ "{:,}".format(grand_total) }}
</th>
</tr>
</tfoot>
{% endif %}
</table>
</div>
</div>
</section>
<style>
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); align-items: center; flex-wrap: wrap; }
</style>
{% endblock %}
@@ -0,0 +1,119 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="erp-ex-settings">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-ghost" href="/expense/">← 개인경비</a>
</div>
<div class="erp-card-block" style="max-width: 640px;">
<div class="erp-card-block-head">
<h2>분류 항목 관리</h2>
<span class="erp-muted">추가/삭제 즉시 사용자 등록 폼에 반영됩니다.</span>
</div>
<form id="cat-form" class="erp-form-grid" style="grid-template-columns: 1fr auto; align-items: end; gap: var(--sp-12);">
<label class="erp-field"><span>새 분류명</span>
<input type="text" name="name" maxlength="30" placeholder="예) 마케팅" required />
</label>
<div class="erp-form-actions" style="margin: 0;">
<button type="submit" class="erp-btn erp-btn-primary">추가</button>
</div>
</form>
<div class="erp-table-wrap" style="margin-top: var(--sp-16);">
<table class="erp-table" id="cat-table">
<thead>
<tr>
<th>분류명</th>
<th style="width: 100px; text-align: right;">동작</th>
</tr>
</thead>
<tbody id="cat-tbody">
{% for c in categories %}
<tr data-name="{{ c }}">
<td>{{ c }}</td>
<td style="text-align: right;">
<button class="erp-btn erp-btn-outline erp-btn-sm js-del">삭제</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="erp-muted" style="margin-top: var(--sp-12);">
삭제해도 이미 등록된 경비 항목의 분류는 그대로 유지됩니다. 분류는 최소 1개 이상이어야 합니다.
</p>
</div>
</section>
<style>
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
#cat-tbody td { vertical-align: middle; }
</style>
{% endblock %}
{% block scripts %}
<script>
(function () {
const form = document.getElementById("cat-form");
const tbody = document.getElementById("cat-tbody");
function rowFor(name) {
const tr = document.createElement("tr");
tr.dataset.name = name;
tr.innerHTML =
`<td></td>` +
`<td style="text-align: right;">` +
`<button class="erp-btn erp-btn-outline erp-btn-sm js-del">삭제</button></td>`;
tr.children[0].textContent = name;
return tr;
}
function render(categories) {
tbody.innerHTML = "";
categories.forEach((c) => tbody.appendChild(rowFor(c)));
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
const name = form.name.value.trim();
if (!name) return;
try {
const res = await fetch("/expense/api/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.status);
render(data.categories);
form.reset();
form.name.focus();
} catch (err) {
alert(`추가 실패: ${err.message || err}`);
}
});
tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button.js-del");
if (!btn) return;
const tr = btn.closest("tr[data-name]");
const name = tr.dataset.name;
if (!confirm(`분류 "${name}" 을(를) 삭제할까요?`)) return;
try {
const res = await fetch(`/expense/api/categories/${encodeURIComponent(name)}`, {
method: "DELETE",
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.status);
render(data.categories);
} catch (err) {
alert(`삭제 실패: ${err.message || err}`);
}
});
})();
</script>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
"""휴가 관리(vacation) 모듈.
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
- 라우터: `router.py` (FastAPI APIRouter, prefix=/vacation)
- 저장소: `db.py` (vacation_db / PostgreSQL 전용) + `store.py` (상수/일수 계산)
- 템플릿: `templates/vacation/`
데이터 저장은 vacation_db 전용이다. VACATION_DB_URL 미설정 시 build_vacation_store 는
None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를 보여준다(앱은 죽지 않음).
권한:
- `vacation` : 휴가 관리 접근
- `vacation_approver` : 승인/반려 (admin 은 항상 통과)
"""
from typing import Any
from .router import router
from .store import HALF_LABELS, HALVES, STATUSES, VACATION_TYPES, compute_days
__all__ = [
"router",
"VACATION_TYPES",
"STATUSES",
"HALVES",
"HALF_LABELS",
"compute_days",
"build_vacation_store",
]
def build_vacation_store(*, dsn: str | None) -> Any:
"""VACATION_DB_URL 이 있으면 VacationDBStore, 없으면 None.
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시.
"""
if not dsn:
return None
from .db import VacationDBStore # 지연 import (개발 환경 deps 없을 수 있음)
return VacationDBStore(dsn)
+509
View File
@@ -0,0 +1,509 @@
"""vacation_db PostgreSQL 저장소.
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — expense/cupang 모듈과 동일 패턴.
- 연결 정보: 환경변수 `VACATION_DB_URL`
(예: postgresql://vacation_app:<pwd>@postgres-db:5432/vacation_db)
- 스키마(테이블/인덱스/트리거/seed)는 앱이 만들지 않는다.
`scripts/sql/vacation_db_init.sql` 을 superuser 가 사전 적용한다.
앱 계정(vacation_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다.
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
휴가 일수는 서버에서 `store.compute_days` 로 재계산하여 저장한다.
공휴일 집합은 vacation_holidays(is_red=TRUE)에서 읽어 계산에 넘긴다(holiday provider).
"""
from __future__ import annotations
import uuid
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Any
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from app.timezone import KST
from .store import EDITABLE_STATUSES, STATUSES, VACATION_TYPES, compute_days, normalize_half
class VacationDBStore:
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()
# ════════════════════════════════════════════════════════════
# 공휴일 (vacation_holidays) — holiday provider
# ════════════════════════════════════════════════════════════
def list_holidays(self, *, year: int | None = None) -> list[dict[str, Any]]:
clause = ""
params: list[Any] = []
if year:
clause = "WHERE EXTRACT(YEAR FROM holiday_date) = %s"
params.append(year)
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM vacation_holidays {clause} ORDER BY holiday_date ASC",
params,
).fetchall()
return [self._holiday_serialize(r) for r in rows]
def red_holiday_set(self, *, date_from: str, date_to: str) -> set[str]:
"""[date_from, date_to] 범위의 is_red=TRUE 공휴일 ISO 날짜 집합.
휴가일수 계산/달력 색상의 단일 진실 공급원.
"""
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT holiday_date FROM vacation_holidays "
"WHERE is_red = TRUE AND holiday_date BETWEEN %s AND %s",
(date_from, date_to),
).fetchall()
out: set[str] = set()
for r in rows:
d = r["holiday_date"]
out.add(d.isoformat() if isinstance(d, date) else str(d))
return out
def upsert_holiday(
self,
*,
holiday_date: str,
name: str,
kind: str = "public",
is_red: bool = True,
) -> dict[str, Any]:
hd = (holiday_date or "").strip()
nm = (name or "").strip()
if not hd or not nm:
raise ValueError("공휴일 날짜와 이름은 필수입니다.")
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO vacation_holidays (holiday_date, name, kind, is_red)
VALUES (%s, %s, %s, %s)
ON CONFLICT (holiday_date) DO UPDATE
SET name = EXCLUDED.name,
kind = EXCLUDED.kind,
is_red = EXCLUDED.is_red
RETURNING *
""",
(hd, nm, (kind or "public").strip(), bool(is_red)),
).fetchone()
return self._holiday_serialize(row)
def delete_holiday(self, *, holiday_id: int) -> None:
with self._pool.connection() as conn:
cur = conn.execute(
"DELETE FROM vacation_holidays WHERE id = %s", (holiday_id,)
)
if cur.rowcount == 0:
raise KeyError(holiday_id)
# ════════════════════════════════════════════════════════════
# 잔여 연차 (vacation_balances)
# ════════════════════════════════════════════════════════════
def get_balance(self, *, user_email: str, year: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM vacation_balances WHERE user_email = %s AND year = %s",
(user_email.lower().strip(), year),
).fetchone()
return self._balance_serialize(row) if row else None
def list_balances(self, *, year: int) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM vacation_balances WHERE year = %s ORDER BY user_email ASC",
(year,),
).fetchall()
return [self._balance_serialize(r) for r in rows]
def upsert_balance(
self, *, user_email: str, year: int, total_days: float, memo: str = ""
) -> dict[str, Any]:
email = (user_email or "").lower().strip()
if not email or "@" not in email:
raise ValueError("올바른 이메일이 필요합니다.")
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO vacation_balances (user_email, year, total_days, memo)
VALUES (%s, %s, %s, %s)
ON CONFLICT (user_email, year) DO UPDATE
SET total_days = EXCLUDED.total_days,
memo = EXCLUDED.memo
RETURNING *
""",
(email, year, total_days, (memo or "").strip()),
).fetchone()
return self._balance_serialize(row)
def used_days(self, *, user_email: str, year: int) -> float:
"""해당 연도 승인된 휴가의 합계 일수(사용 연차). start_date 연도 기준."""
with self._pool.connection() as conn:
row = conn.execute(
"SELECT COALESCE(SUM(days), 0) AS s FROM vacation_requests "
"WHERE owner = %s AND status = '승인' "
"AND EXTRACT(YEAR FROM start_date) = %s",
(user_email.lower().strip(), year),
).fetchone()
return float(row["s"]) if row else 0.0
def balance_summary(self, *, user_email: str, year: int) -> dict[str, Any]:
bal = self.get_balance(user_email=user_email, year=year)
total = float(bal["total_days"]) if bal else 0.0
used = self.used_days(user_email=user_email, year=year)
return {
"year": year,
"total_days": total,
"used_days": used,
"remaining_days": round(total - used, 2),
"memo": bal["memo"] if bal else "",
}
# ════════════════════════════════════════════════════════════
# 휴가 신청 (vacation_requests)
# ════════════════════════════════════════════════════════════
def list_for(self, owner: str) -> list[dict[str, Any]]:
owner = owner.lower().strip()
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM vacation_requests WHERE owner = %s "
"ORDER BY start_date DESC, created_at DESC",
(owner,),
).fetchall()
return [self._req_serialize(r) for r in rows]
def list_overlapping(self, *, date_from: str, date_to: str) -> list[dict[str, Any]]:
"""[date_from, date_to] 와 겹치는 모든 신청(달력 bar 표시용).
취소 포함(흐리게 표시). 범위가 한 칸이라도 겹치면 포함.
"""
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM vacation_requests "
"WHERE start_date <= %s AND end_date >= %s "
"ORDER BY start_date ASC, owner ASC",
(date_to, date_from),
).fetchall()
return [self._req_serialize(r) for r in rows]
def get_request(self, *, request_id: str) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM vacation_requests WHERE id = %s", (request_id,)
).fetchone()
return self._req_serialize(row) if row else None
def create_request(
self, *, owner: str, owner_name: str, payload: dict[str, Any], status: str = "작성중"
) -> dict[str, Any]:
h = self._normalize_payload(payload)
if status not in STATUSES:
status = "작성중"
days = self._calc_days(h)
req_id = uuid.uuid4().hex[:12]
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO vacation_requests
(id, owner, owner_name, vacation_type, start_date, end_date,
start_half, end_half, days, reason, status)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
req_id,
owner.lower().strip(),
(owner_name or "").strip(),
h["vacation_type"],
h["start_date"],
h["end_date"],
h["start_half"],
h["end_half"],
days,
h["reason"],
status,
),
).fetchone()
return self._req_serialize(row)
def update_request(
self, *, request_id: str, owner: str, payload: dict[str, Any]
) -> dict[str, Any]:
"""작성중/반려 상태에서만 본인이 수정 가능."""
owner = owner.lower().strip()
current = self.get_request(request_id=request_id)
if not current:
raise KeyError(request_id)
if current["owner"] != owner:
raise PermissionError("본인 신청만 수정할 수 있습니다.")
if current["status"] not in EDITABLE_STATUSES:
raise ValueError(f"{current['status']} 상태에서는 수정할 수 없습니다.")
h = self._normalize_payload(payload)
days = self._calc_days(h)
with self._pool.connection() as conn:
row = conn.execute(
"""
UPDATE vacation_requests
SET vacation_type = %s, start_date = %s, end_date = %s,
start_half = %s, end_half = %s, days = %s, reason = %s,
status = '작성중', reject_reason = '',
approver_email = NULL, decided_at = NULL
WHERE id = %s AND owner = %s
RETURNING *
""",
(
h["vacation_type"],
h["start_date"],
h["end_date"],
h["start_half"],
h["end_half"],
days,
h["reason"],
request_id,
owner,
),
).fetchone()
if not row:
raise KeyError(request_id)
return self._req_serialize(row)
# ── 워크플로 ──
def submit(self, *, request_id: str, owner: str) -> dict[str, Any]:
"""작성중/반려 → 제출 (본인)."""
owner = owner.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"""
UPDATE vacation_requests
SET status = '제출', reject_reason = '',
approver_email = NULL, decided_at = NULL
WHERE id = %s AND owner = %s AND status IN ('작성중', '반려')
RETURNING *
""",
(request_id, owner),
).fetchone()
if not row:
raise ValueError("작성중/반려 상태에서만 제출할 수 있습니다.")
return self._req_serialize(row)
def approve(self, *, request_id: str, approver_email: str) -> dict[str, Any]:
approver_email = approver_email.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"""
UPDATE vacation_requests
SET status = '승인', approver_email = %s, decided_at = now(),
reject_reason = ''
WHERE id = %s AND status = '제출'
RETURNING *
""",
(approver_email, request_id),
).fetchone()
if not row:
raise ValueError("제출 상태가 아니거나 신청이 없습니다.")
return self._req_serialize(row)
def reject(
self, *, request_id: str, approver_email: str, reason: str
) -> dict[str, Any]:
if not (reason or "").strip():
raise ValueError("반려 사유는 필수입니다.")
approver_email = approver_email.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"""
UPDATE vacation_requests
SET status = '반려', approver_email = %s, decided_at = now(),
reject_reason = %s
WHERE id = %s AND status = '제출'
RETURNING *
""",
(approver_email, reason.strip(), request_id),
).fetchone()
if not row:
raise ValueError("제출 상태가 아니거나 신청이 없습니다.")
return self._req_serialize(row)
def cancel(self, *, request_id: str, user_email: str, is_admin: bool) -> dict[str, Any]:
"""soft delete — status='취소'. owner 또는 admin 만."""
user_email = user_email.lower().strip()
current = self.get_request(request_id=request_id)
if not current:
raise KeyError(request_id)
if not is_admin and current["owner"] != user_email:
raise PermissionError("본인 신청만 취소할 수 있습니다.")
with self._pool.connection() as conn:
row = conn.execute(
"UPDATE vacation_requests SET status = '취소' WHERE id = %s RETURNING *",
(request_id,),
).fetchone()
return self._req_serialize(row)
def hard_delete(self, *, request_id: str, user_email: str, is_admin: bool) -> None:
"""완전 삭제.
- 관리자(is_admin): 모든 상태 삭제 가능.
- 일반 사용자: 본인 + 승인 전(작성중/제출/반려)만. 승인/취소 건은 불가.
"""
user_email = user_email.lower().strip()
current = self.get_request(request_id=request_id)
if not current:
raise KeyError(request_id)
if not is_admin:
if current["owner"] != user_email:
raise PermissionError("본인 신청만 삭제할 수 있습니다.")
if current["status"] not in ("작성중", "제출", "반려"):
raise ValueError("승인/취소된 휴가는 삭제할 수 없습니다. (관리자 문의)")
with self._pool.connection() as conn:
cur = conn.execute(
"DELETE FROM vacation_requests WHERE id = %s", (request_id,)
)
if cur.rowcount == 0:
raise KeyError(request_id)
def list_pending(self) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM vacation_requests WHERE status = '제출' "
"ORDER BY start_date ASC, created_at ASC"
).fetchall()
return [self._req_serialize(r) for r in rows]
def list_for_export(
self, *, date_from: str | None = None, date_to: str | None = None
) -> list[dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if date_from:
clauses.append("end_date >= %s")
params.append(date_from)
if date_to:
clauses.append("start_date <= %s")
params.append(date_to)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM vacation_requests {where} "
"ORDER BY start_date ASC, owner ASC",
params,
).fetchall()
return [self._req_serialize(r) for r in rows]
# ════════════════════════════════════════════════════════════
# 정규화 / 계산 / 직렬화 helpers
# ════════════════════════════════════════════════════════════
def _calc_days(self, h: dict[str, Any]) -> float:
holidays = self.red_holiday_set(
date_from=h["start_date"], date_to=h["end_date"]
)
return compute_days(
start_date=date.fromisoformat(h["start_date"]),
end_date=date.fromisoformat(h["end_date"]),
start_half=h["start_half"],
end_half=h["end_half"],
vacation_type=h["vacation_type"],
holidays=holidays,
)
@staticmethod
def _normalize_payload(payload: dict[str, Any]) -> dict[str, Any]:
vtype = str(payload.get("vacation_type") or "연차").strip()
if vtype not in VACATION_TYPES:
vtype = "연차"
start_date = str(payload.get("start_date") or "").strip()
end_date = str(payload.get("end_date") or "").strip()
if not start_date:
raise ValueError("시작일은 필수입니다.")
if not end_date:
end_date = start_date
# 날짜 형식/순서 검증
try:
sd = date.fromisoformat(start_date)
ed = date.fromisoformat(end_date)
except ValueError:
raise ValueError("날짜 형식이 올바르지 않습니다 (YYYY-MM-DD).")
if ed < sd:
raise ValueError("종료일이 시작일보다 빠를 수 없습니다.")
start_half = normalize_half(payload.get("start_half"))
end_half = normalize_half(payload.get("end_half"))
# 반차 종류면 단일 일자로 강제
if vtype in ("오전반차", "오후반차"):
end_date = start_date
start_half = "am" if vtype == "오전반차" else "pm"
end_half = start_half
return {
"vacation_type": vtype,
"start_date": start_date,
"end_date": end_date,
"start_half": start_half,
"end_half": end_half,
"reason": str(payload.get("reason") or "").strip(),
}
@staticmethod
def _num(v: Any) -> float:
if isinstance(v, Decimal):
return float(v)
try:
return float(v)
except (TypeError, ValueError):
return 0.0
@classmethod
def _req_serialize(cls, row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
for k in ("start_date", "end_date"):
v = out.get(k)
if isinstance(v, date):
out[k] = v.isoformat()
for k in ("created_at", "updated_at", "decided_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
out["days"] = cls._num(out.get("days", 0))
return out
@staticmethod
def _holiday_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
out["id"] = int(out["id"])
v = out.get("holiday_date")
if isinstance(v, date):
out["holiday_date"] = v.isoformat()
out["is_red"] = bool(out.get("is_red", True))
for k in ("created_at", "updated_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
return out
@classmethod
def _balance_serialize(cls, row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
out["id"] = int(out["id"])
out["year"] = int(out["year"])
out["total_days"] = cls._num(out.get("total_days", 0))
out["used_days"] = cls._num(out.get("used_days", 0))
for k in ("created_at", "updated_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
return out
+827
View File
@@ -0,0 +1,827 @@
"""휴가 관리 모듈 라우터.
- 경로: /vacation
- 권한: 로그인 + `vacation` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
승인/반려는 `vacation_approver` 또는 admin.
- 데이터: VacationDBStore (vacation_db / PostgreSQL) 전용.
VACATION_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내를 보여준다.
"""
from __future__ import annotations
import calendar as _calendar
import io
from datetime import date
from typing import Any
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import (
HTMLResponse,
JSONResponse,
RedirectResponse,
StreamingResponse,
)
from app.timezone import now_kst, today_kst
from .store import HALF_LABELS, HALVES, VACATION_TYPES
router = APIRouter(prefix="/vacation", tags=["vacation"])
# ────────────────────────────────────────────────────────────
# 공용 헬퍼
# ────────────────────────────────────────────────────────────
def _store(request: Request) -> Any:
return getattr(request.app.state, "vacation_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, "vacation"):
raise HTTPException(status_code=403, detail="휴가 관리 모듈 권한이 없습니다.")
return user
def _require_approver(request: Request) -> dict[str, Any]:
from app.main import get_current_user_record # noqa: WPS433
from app.store import has_module, is_admin # noqa: WPS433
user = get_current_user_record(request)
if user is None:
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
if not (is_admin(user) or has_module(user, "vacation_approver")):
raise HTTPException(status_code=403, detail="휴가 승인자 권한이 필요합니다.")
return user
def _is_approver(request: Request, user: dict[str, Any]) -> bool:
from app.store import has_module, is_admin # noqa: WPS433
return is_admin(user) or has_module(user, "vacation_approver")
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": "휴가 관리 모듈이 아직 설정되지 않았습니다. "
"VACATION_DB_URL 환경변수를 설정하고 scripts/sql/vacation_db_init.sql 로 "
"vacation_db 를 초기화한 뒤 컨테이너를 재기동하세요.",
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="vacation"),
},
status_code=503,
)
def _guard(
request: Request,
) -> tuple[Any, dict[str, Any]] | HTMLResponse | RedirectResponse:
"""로그인+권한+store 점검. 페이지 핸들러 진입부에서 사용."""
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, "vacation"):
return render_template(
request,
"denied.html",
{"reason": "휴가 관리 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)},
status_code=403,
)
store = _store(request)
if store is None:
return _render_config_needed(request, user)
return store, user
def _ym(request: Request) -> tuple[int, int]:
today = today_kst()
try:
year = int(request.query_params.get("year") or today.year)
month = int(request.query_params.get("month") or today.month)
except ValueError:
year, month = today.year, today.month
if not (1 <= month <= 12):
year, month = today.year, today.month
return year, month
# 상태별 bar 스타일 클래스 (CSS 와 매핑)
_STATUS_CLASS = {
"작성중": "vac-bar-draft",
"제출": "vac-bar-submit",
"승인": "vac-bar-approve",
"반려": "vac-bar-reject",
"취소": "vac-bar-cancel",
}
def _assign_lanes(bars: list[dict[str, Any]]) -> int:
"""주(week) 내 bar 들에 겹치지 않는 lane(행) 번호를 그리디 배정.
bars 는 같은 주의 segment 들. 각 bar 에 'lane' 키를 추가하고, 사용된 lane 수를 반환.
"""
bars.sort(key=lambda b: (b["start_col"], -b["span"]))
lane_end: list[int] = [] # lane 별 마지막 점유 col(포함)
for b in bars:
placed = False
for li, end_col in enumerate(lane_end):
if b["start_col"] > end_col:
b["lane"] = li
lane_end[li] = b["start_col"] + b["span"] - 1
placed = True
break
if not placed:
b["lane"] = len(lane_end)
lane_end.append(b["start_col"] + b["span"] - 1)
return len(lane_end)
def _build_calendar(
store: Any, year: int, month: int, sel: str
) -> dict[str, Any]:
"""월간 달력 데이터 + bar lane 레이아웃 계산."""
cal = _calendar.Calendar(firstweekday=6) # 일요일 시작
weeks_dates = cal.monthdatescalendar(year, month)
range_start = weeks_dates[0][0]
range_end = weeks_dates[-1][-1]
red_set = store.red_holiday_set(
date_from=range_start.isoformat(), date_to=range_end.isoformat()
)
holiday_names = {
h["holiday_date"]: h["name"]
for h in store.list_holidays()
if h.get("is_red")
}
requests = store.list_overlapping(
date_from=range_start.isoformat(), date_to=range_end.isoformat()
)
today = today_kst()
weeks: list[dict[str, Any]] = []
for week in weeks_dates:
w_start, w_end = week[0], week[-1]
days = [
{
"date": d.isoformat(),
"day": d.day,
"in_month": d.month == month,
"is_today": d == today,
"is_selected": d.isoformat() == sel,
"is_sunday": d.weekday() == 6,
"is_saturday": d.weekday() == 5,
"is_holiday": d.isoformat() in red_set,
"holiday_name": holiday_names.get(d.isoformat(), ""),
}
for d in week
]
# 이 주에 걸치는 bar segment
bars: list[dict[str, Any]] = []
for r in requests:
rs = date.fromisoformat(r["start_date"])
re_ = date.fromisoformat(r["end_date"])
if re_ < w_start or rs > w_end:
continue
seg_start = max(rs, w_start)
seg_end = min(re_, w_end)
start_col = (seg_start - w_start).days # 0..6
span = (seg_end - seg_start).days + 1
label = f"{r.get('owner_name') or r.get('owner')} {r['vacation_type']}"
bars.append(
{
"id": r["id"],
"label": label,
"status": r["status"],
"status_class": _STATUS_CLASS.get(r["status"], "vac-bar-draft"),
"start_col": start_col,
"span": span,
"continues_left": rs < w_start,
"continues_right": re_ > w_end,
"days": r["days"],
}
)
lane_count = _assign_lanes(bars)
weeks.append({"days": days, "bars": bars, "lane_count": lane_count})
return {"weeks": weeks, "requests": requests, "sel_date": sel}
# ════════════════════════════════════════════════════════════
# 메인 — 월간 달력 + 선택일 휴가 리스트
# ════════════════════════════════════════════════════════════
@router.get("/", response_class=HTMLResponse)
async def index(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
year, month = _ym(request)
sel = request.query_params.get("date") or ""
today = today_kst()
if not sel:
sel = (
today.isoformat()
if (today.year == year and today.month == month)
else f"{year:04d}-{month:02d}-01"
)
caldata = _build_calendar(store, year, month, sel)
sel_requests = [
r
for r in caldata["requests"]
if r["start_date"] <= sel <= r["end_date"]
]
prev_y, prev_m = (year - 1, 12) if month == 1 else (year, month - 1)
next_y, next_m = (year + 1, 1) if month == 12 else (year, month + 1)
summary = store.balance_summary(user_email=user["email"], year=year)
is_approver = _is_approver(request, user)
pending_count = len(store.list_pending()) if is_approver else 0
return render_template(
request,
"vacation/index.html",
{
"user": user,
"is_admin": is_admin(user),
"is_approver": is_approver,
"pending_count": pending_count,
"nav_items": build_erp_nav(user, active="vacation"),
"page_title": "휴가 관리",
"page_subtitle": f"{year}{month}월 휴가 달력",
"year": year,
"month": month,
"prev_y": prev_y, "prev_m": prev_m,
"next_y": next_y, "next_m": next_m,
"today": today.isoformat(),
"weekdays": ["", "", "", "", "", "", ""],
"weeks": caldata["weeks"],
"selected_date": sel,
"sel_requests": sel_requests,
"balance": summary,
"half_labels": HALF_LABELS,
},
)
# ════════════════════════════════════════════════════════════
# 휴가 신청 — 등록 / 수정 / 상세
# ════════════════════════════════════════════════════════════
def _form_context(request: Request, user: dict[str, Any]) -> 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_approver": _is_approver(request, user),
"nav_items": build_erp_nav(user, active="vacation"),
"vacation_types": list(VACATION_TYPES),
"halves": list(HALVES),
"half_labels": HALF_LABELS,
}
@router.get("/new", response_class=HTMLResponse)
async def new_form(request: Request) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
_store_obj, user = guard
ctx = _form_context(request, user)
ctx.update(
{
"page_title": "휴가 신청",
"page_subtitle": "휴가 종류 · 기간 · 사유 입력",
"mode": "new",
"req": None,
"default_date": today_kst().isoformat(),
}
)
return render_template(request, "vacation/form.html", ctx)
def _payload_from_form(
vacation_type: str,
start_date: str,
end_date: str,
start_half: str,
end_half: str,
reason: str,
) -> dict[str, Any]:
return {
"vacation_type": vacation_type,
"start_date": start_date,
"end_date": end_date or start_date,
"start_half": start_half,
"end_half": end_half,
"reason": reason,
}
@router.post("/new")
async def create(
request: Request,
vacation_type: str = Form(...),
start_date: str = Form(...),
end_date: str = Form(""),
start_half: str = Form("full"),
end_half: str = Form("full"),
reason: str = Form(""),
action: str = Form("submit"), # "draft" | "submit"
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="vacation_db 미설정")
status = "작성중" if action == "draft" else "제출"
payload = _payload_from_form(
vacation_type, start_date, end_date, start_half, end_half, reason
)
try:
req = store.create_request(
owner=user["email"],
owner_name=user.get("name") or user["email"],
payload=payload,
status=status,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/vacation/{req['id']}", status_code=303)
@router.get("/pending", response_class=HTMLResponse)
async def pending_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
if not _is_approver(request, user):
return render_template(
request,
"denied.html",
{"reason": "휴가 승인자 권한이 없습니다.", "is_admin": is_admin(user)},
status_code=403,
)
pending = store.list_pending()
return render_template(
request,
"vacation/pending.html",
{
"user": user,
"is_admin": is_admin(user),
"is_approver": True,
"nav_items": build_erp_nav(user, active="vacation"),
"page_title": "휴가 — 승인 대기",
"page_subtitle": f"제출 상태 {len(pending)}",
"items": pending,
"half_labels": HALF_LABELS,
},
)
@router.get("/settings", response_class=HTMLResponse)
async def settings_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
if not is_admin(user):
return render_template(
request,
"denied.html",
{"reason": "휴가 설정은 관리자만 접근할 수 있습니다.", "is_admin": False},
status_code=403,
)
today = today_kst()
try:
year = int(request.query_params.get("year") or today.year)
except ValueError:
year = today.year
balances = store.list_balances(year=year)
for b in balances:
b["used_days"] = store.used_days(user_email=b["user_email"], year=year)
return render_template(
request,
"vacation/settings.html",
{
"user": user,
"is_admin": True,
"is_approver": True,
"nav_items": build_erp_nav(user, active="vacation"),
"page_title": "휴가 — 설정",
"page_subtitle": "공휴일 · 연차 잔여 관리",
"year": year,
"holidays": store.list_holidays(year=year),
"balances": balances,
},
)
@router.post("/settings/holidays")
async def holiday_upsert(
request: Request,
holiday_date: str = Form(...),
name: str = Form(...),
kind: str = Form("public"),
is_red: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
from app.store import is_admin # noqa: WPS433
if not is_admin(user):
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="vacation_db 미설정")
try:
store.upsert_holiday(
holiday_date=holiday_date,
name=name,
kind=kind,
is_red=is_red in ("1", "true", "on", "True"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
year = holiday_date[:4]
return RedirectResponse(url=f"/vacation/settings?year={year}", status_code=303)
@router.post("/settings/holidays/{holiday_id:int}/delete")
async def holiday_delete(
request: Request,
holiday_id: int,
year: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
from app.store import is_admin # noqa: WPS433
if not is_admin(user):
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="vacation_db 미설정")
try:
store.delete_holiday(holiday_id=holiday_id)
except KeyError:
raise HTTPException(status_code=404, detail="공휴일을 찾을 수 없습니다.")
suffix = f"?year={year}" if year else ""
return RedirectResponse(url=f"/vacation/settings{suffix}", status_code=303)
@router.post("/settings/balances")
async def balance_upsert(
request: Request,
user_email: str = Form(...),
year: int = Form(...),
total_days: float = Form(...),
memo: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
from app.store import is_admin # noqa: WPS433
if not is_admin(user):
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="vacation_db 미설정")
try:
store.upsert_balance(
user_email=user_email, year=year, total_days=total_days, memo=memo
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/vacation/settings?year={year}", status_code=303)
@router.get("/api/calendar")
async def api_calendar(
request: Request, user: dict[str, Any] = Depends(_require_user)
) -> JSONResponse:
"""달력 비동기 데이터(JSON). year/month 쿼리."""
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="vacation_db 미설정")
year, month = _ym(request)
sel = request.query_params.get("date") or f"{year:04d}-{month:02d}-01"
caldata = _build_calendar(store, year, month, sel)
return JSONResponse(
{
"year": year,
"month": month,
"weeks": caldata["weeks"],
"requests": caldata["requests"],
}
)
@router.get("/export.xlsx")
async def export_xlsx(
request: Request, user: dict[str, Any] = Depends(_require_user)
) -> StreamingResponse:
from openpyxl import Workbook # 지연 import
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="vacation_db 미설정")
today = today_kst()
try:
year = int(request.query_params.get("year") or today.year)
month = int(request.query_params.get("month") or 0)
except ValueError:
year, month = today.year, 0
if 1 <= month <= 12:
last = _calendar.monthrange(year, month)[1]
date_from = f"{year:04d}-{month:02d}-01"
date_to = f"{year:04d}-{month:02d}-{last:02d}"
else:
date_from = f"{year:04d}-01-01"
date_to = f"{year:04d}-12-31"
rows = store.list_for_export(date_from=date_from, date_to=date_to)
wb = Workbook()
ws = wb.active
ws.title = "vacation"
header = [
"신청자", "휴가종류", "시작일", "종료일", "시작구분", "종료구분",
"사용일수", "상태", "승인자", "승인/반려일", "사유", "반려사유",
]
ws.append(header)
for r in rows:
ws.append([
r.get("owner_name") or r.get("owner", ""),
r.get("vacation_type", ""),
r.get("start_date", ""),
r.get("end_date", ""),
HALF_LABELS.get(r.get("start_half", "full"), ""),
HALF_LABELS.get(r.get("end_half", "full"), ""),
r.get("days", 0),
r.get("status", ""),
r.get("approver_email", "") or "",
r.get("decided_at", "") or "",
r.get("reason", "") or "",
r.get("reject_reason", "") or "",
])
widths = [24, 10, 12, 12, 8, 8, 8, 8, 24, 22, 30, 30]
for col, w in enumerate(widths, start=1):
ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = w
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
scope = f"{year}{('%02d' % month) if (1 <= month <= 12) else ''}"
fname = f"vacation_{scope}_{now_kst().strftime('%Y%m%d_%H%M%S')}.xlsx"
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "module": "vacation"}
@router.get("/{request_id}", response_class=HTMLResponse)
async def detail(request: Request, request_id: str) -> 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
req = store.get_request(request_id=request_id)
if not req:
return render_template(
request, "denied.html",
{"reason": "휴가 신청을 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
is_owner = req["owner"] == user["email"]
is_approver = _is_approver(request, user)
if not (is_owner or is_approver):
return render_template(
request, "denied.html",
{"reason": "본인 또는 승인자만 조회할 수 있습니다.", "is_admin": is_admin(user)},
status_code=403,
)
return render_template(
request,
"vacation/detail.html",
{
"user": user,
"is_admin": is_admin(user),
"is_approver": is_approver,
"is_owner": is_owner,
"nav_items": build_erp_nav(user, active="vacation"),
"page_title": "휴가 상세",
"page_subtitle": f"{req['start_date']} · {req['vacation_type']}",
"req": req,
"half_labels": HALF_LABELS,
"can_edit": is_owner and req["status"] in ("작성중", "반려"),
},
)
@router.get("/{request_id}/edit", response_class=HTMLResponse)
async def edit_form(request: Request, request_id: str) -> HTMLResponse:
from app.main import 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
req = store.get_request(request_id=request_id)
if not req:
return render_template(
request, "denied.html",
{"reason": "휴가 신청을 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
if req["owner"] != user["email"]:
return render_template(
request, "denied.html",
{"reason": "본인 신청만 수정할 수 있습니다.", "is_admin": is_admin(user)},
status_code=403,
)
if req["status"] not in ("작성중", "반려"):
return render_template(
request, "denied.html",
{"reason": f"{req['status']} 상태에서는 수정할 수 없습니다.", "is_admin": is_admin(user)},
status_code=409,
)
ctx = _form_context(request, user)
ctx.update(
{
"page_title": "휴가 신청 수정",
"page_subtitle": "작성중/반려 상태만 수정 가능",
"mode": "edit",
"req": req,
"default_date": req["start_date"],
}
)
return render_template(request, "vacation/form.html", ctx)
@router.post("/{request_id}/edit")
async def update(
request: Request,
request_id: str,
vacation_type: str = Form(...),
start_date: str = Form(...),
end_date: str = Form(""),
start_half: str = Form("full"),
end_half: str = Form("full"),
reason: str = Form(""),
action: str = Form("save"), # "save" | "submit"
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="vacation_db 미설정")
payload = _payload_from_form(
vacation_type, start_date, end_date, start_half, end_half, reason
)
try:
store.update_request(request_id=request_id, owner=user["email"], payload=payload)
if action == "submit":
store.submit(request_id=request_id, owner=user["email"])
except KeyError:
raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.")
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
@router.post("/{request_id}/submit")
async def submit(
request: Request,
request_id: str,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="vacation_db 미설정")
try:
store.submit(request_id=request_id, owner=user["email"])
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc))
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
@router.post("/{request_id}/approve")
async def approve(
request: Request,
request_id: str,
user: dict[str, Any] = Depends(_require_approver),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="vacation_db 미설정")
try:
store.approve(request_id=request_id, approver_email=user["email"])
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc))
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
@router.post("/{request_id}/reject")
async def reject(
request: Request,
request_id: str,
reject_reason: str = Form(...),
user: dict[str, Any] = Depends(_require_approver),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="vacation_db 미설정")
try:
store.reject(
request_id=request_id, approver_email=user["email"], reason=reject_reason
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc))
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
@router.post("/{request_id}/cancel")
async def cancel(
request: Request,
request_id: str,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
from app.store import is_admin # noqa: WPS433
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="vacation_db 미설정")
try:
store.cancel(
request_id=request_id, user_email=user["email"], is_admin=is_admin(user)
)
except KeyError:
raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.")
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc))
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
@router.post("/{request_id}/delete")
async def delete(
request: Request,
request_id: str,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
"""완전 삭제 — 승인 전(작성중/제출/반려)만. 달력으로 복귀."""
from app.store import is_admin # noqa: WPS433
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="vacation_db 미설정")
try:
store.hard_delete(
request_id=request_id, user_email=user["email"], is_admin=is_admin(user)
)
except KeyError:
raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.")
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc))
return RedirectResponse(url="/vacation/", status_code=303)
+99
View File
@@ -0,0 +1,99 @@
"""휴가 관리 모듈 상수 및 순수 계산 헬퍼.
- 데이터 저장은 vacation_db(PostgreSQL) 전용이다(`db.py`).
운영 데이터가 JSON 과 DB 로 갈라지는 것을 막기 위해 JSON 폴백을 두지 않는다.
VACATION_DB_URL 미설정 시 라우터가 "설정 필요" 안내 페이지를 보여준다.
- 이 모듈에는 상수와 순수 계산 헬퍼(휴가일수 계산)만 둔다.
공휴일 집합은 db.py 가 vacation_holidays 에서 읽어 넘겨준다(holiday provider 분리).
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import Iterable
# 휴가 종류 (한글 라벨 그대로 저장)
VACATION_TYPES: tuple[str, ...] = (
"연차",
"오전반차",
"오후반차",
"병가",
"경조",
"대체휴무",
"기타",
)
# 신청 상태 워크플로
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "취소")
# 시작/종료일 구분
HALVES: tuple[str, ...] = ("full", "am", "pm")
HALF_LABELS: dict[str, str] = {"full": "종일", "am": "오전", "pm": "오후"}
# 수정/삭제(취소) 가능한 상태 — 본인 편집 허용
EDITABLE_STATUSES: tuple[str, ...] = ("작성중", "반려")
# 반차 성격의 휴가 종류 (단일 일자 0.5일 강제)
_HALF_DAY_TYPES: dict[str, str] = {"오전반차": "am", "오후반차": "pm"}
def _daterange(start: date, end: date) -> Iterable[date]:
cur = start
while cur <= end:
yield cur
cur += timedelta(days=1)
def is_working_day(d: date, holidays: set[str]) -> bool:
"""주말(토/일)과 공휴일(holidays: ISO 날짜 집합)을 제외하면 근무일."""
if d.weekday() >= 5: # 5=토, 6=일
return False
return d.isoformat() not in holidays
def compute_days(
*,
start_date: date,
end_date: date,
start_half: str = "full",
end_half: str = "full",
vacation_type: str = "연차",
holidays: set[str] | None = None,
) -> float:
"""휴가 일수 계산. 서버에서 항상 이 함수로 재계산한다(클라이언트 신뢰 금지).
- 주말 + 공휴일(is_red) 제외.
- 오전/오후 반차는 0.5일.
- 같은 날 + 반차면 0.5일.
- 여러 날에서 시작/종료가 반차면 시작일/종료일 각각 0.5 차감.
- 휴가 종류가 오전반차/오후반차면 단일 일자 0.5일로 강제.
"""
holidays = holidays or set()
if end_date < start_date:
return 0.0
# 반차 종류는 단일 일자 0.5
if vacation_type in _HALF_DAY_TYPES:
if is_working_day(start_date, holidays):
return 0.5
return 0.0
working = [d for d in _daterange(start_date, end_date) if is_working_day(d, holidays)]
n = len(working)
if n == 0:
return 0.0
if start_date == end_date:
return 0.5 if start_half in ("am", "pm") else 1.0
total = float(n)
if start_half in ("am", "pm") and is_working_day(start_date, holidays):
total -= 0.5
if end_half in ("am", "pm") and is_working_day(end_date, holidays):
total -= 0.5
return max(0.0, total)
def normalize_half(value: str | None) -> str:
v = (value or "full").strip().lower()
return v if v in HALVES else "full"
@@ -0,0 +1,97 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530j" />{% endblock %}
{% block content %}
<section class="vac">
<div class="erp-card vac-detail-card">
<div class="vac-detail-head">
<div>
<h2>{{ req.owner_name or req.owner }}</h2>
<span class="erp-muted">{{ req.owner }}</span>
</div>
{% set badge = 'erp-badge-neutral' %}
{% if req.status == '승인' %}{% set badge = 'erp-badge-success' %}
{% elif req.status == '제출' %}{% set badge = 'erp-badge-inverse' %}
{% elif req.status == '반려' %}{% set badge = 'erp-badge-danger' %}{% endif %}
<span class="erp-badge {{ badge }} vac-detail-status">{{ req.status }}</span>
</div>
<dl class="vac-detail-grid">
<div><dt>휴가 종류</dt><dd>{{ req.vacation_type }}</dd></div>
<div><dt>사용 일수</dt><dd><strong>{{ req.days }}</strong></dd></div>
<div><dt>시작일</dt><dd>{{ req.start_date }} ({{ half_labels[req.start_half] }})</dd></div>
<div><dt>종료일</dt><dd>{{ req.end_date }} ({{ half_labels[req.end_half] }})</dd></div>
{% if req.approver_email %}
<div><dt>승인자</dt><dd>{{ req.approver_email }}</dd></div>
<div><dt>결재일시</dt><dd>{{ req.decided_at }}</dd></div>
{% endif %}
</dl>
{% if req.reason %}
<div class="vac-detail-block">
<div class="vac-detail-label">사유</div>
<p>{{ req.reason }}</p>
</div>
{% endif %}
{% if req.status == '반려' and req.reject_reason %}
<div class="vac-detail-block vac-reject-block">
<div class="vac-detail-label">반려 사유</div>
<p>{{ req.reject_reason }}</p>
</div>
{% endif %}
<!-- ── 승인자 액션 (제출 상태) ── -->
{% if is_approver and req.status == '제출' %}
<div class="vac-approve-box">
<form method="post" action="/vacation/{{ req.id }}/approve" class="vac-inline-form">
<button type="submit" class="erp-btn erp-btn-primary">승인</button>
</form>
<form method="post" action="/vacation/{{ req.id }}/reject" class="vac-reject-form">
<input class="erp-input" type="text" name="reject_reason" placeholder="반려 사유" required />
<button type="submit" class="erp-btn erp-btn-danger">반려</button>
</form>
</div>
{% endif %}
<div class="erp-page-actions vac-detail-actions">
<a class="erp-btn erp-btn-primary" href="/vacation/">◀◀ 달력</a>
{% if is_owner and req.status in ('작성중', '반려') %}
<form method="post" action="/vacation/{{ req.id }}/submit" class="vac-inline-form">
<button type="submit" class="erp-btn erp-btn-outline">제출</button>
</form>
{% endif %}
{# 승인 건: 취소처리(owner/admin). 미승인: 삭제(owner/admin). 관리자는 모든 상태 삭제 가능. #}
{% if req.status == '승인' and (is_owner or is_admin) %}
<form method="post" action="/vacation/{{ req.id }}/cancel" class="vac-inline-form"
onsubmit="return confirm('승인된 휴가를 취소 처리할까요?');">
<button type="submit" class="erp-btn erp-btn-outline">취소처리</button>
</form>
{% endif %}
{% if req.status in ('작성중', '제출', '반려') and (is_owner or is_admin) %}
<form method="post" action="/vacation/{{ req.id }}/delete" class="vac-inline-form"
onsubmit="return confirm('이 휴가 신청을 삭제할까요? (복구 불가)');">
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
</form>
{% endif %}
{% if is_admin and req.status in ('승인', '취소') %}
<form method="post" action="/vacation/{{ req.id }}/delete" class="vac-inline-form"
onsubmit="return confirm('[관리자] 이 휴가({{ req.status }})를 완전 삭제할까요? (복구 불가)');">
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
</form>
{% endif %}
{% if can_edit %}
<a class="erp-btn erp-btn-outline vac-push-right" href="/vacation/{{ req.id }}/edit">수정</a>
{% endif %}
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,71 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
{% block content %}
<section class="vac">
{% set action = '/vacation/new' if mode == 'new' else '/vacation/' ~ req.id ~ '/edit' %}
<form id="vac-form" method="post" action="{{ action }}" class="erp-card vac-form-card">
<div class="vac-form-grid">
<label class="erp-field"><span>휴가 종류 *</span>
<select class="erp-select" name="vacation_type" id="vac-type" required>
{% for t in vacation_types %}
<option value="{{ t }}" {% if req and req.vacation_type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</label>
<label class="erp-field"><span>시작일 *</span>
<input class="erp-input" type="date" name="start_date" id="vac-start" required
value="{{ req.start_date if req else default_date }}" />
</label>
<label class="erp-field" id="vac-start-half-field"><span>시작 구분</span>
<select class="erp-select" name="start_half" id="vac-start-half">
{% for h in halves %}
<option value="{{ h }}" {% if req and req.start_half == h %}selected{% endif %}>{{ half_labels[h] }}</option>
{% endfor %}
</select>
</label>
<label class="erp-field" id="vac-end-field"><span>종료일 *</span>
<input class="erp-input" type="date" name="end_date" id="vac-end"
value="{{ req.end_date if req else default_date }}" />
</label>
<label class="erp-field" id="vac-end-half-field"><span>종료 구분</span>
<select class="erp-select" name="end_half" id="vac-end-half">
{% for h in halves %}
<option value="{{ h }}" {% if req and req.end_half == h %}selected{% endif %}>{{ half_labels[h] }}</option>
{% endfor %}
</select>
</label>
</div>
<label class="erp-field vac-full"><span>사유</span>
<textarea class="erp-input" name="reason" rows="3"
placeholder="휴가 사유를 입력하세요.">{{ req.reason if req else '' }}</textarea>
</label>
<div class="vac-days-preview">
예상 사용 일수: <strong id="vac-days-out"></strong>
<span class="erp-muted">(주말 제외 · 공휴일은 저장 시 반영)</span>
</div>
<div class="erp-page-actions vac-form-actions">
<button type="submit" class="erp-btn erp-btn-primary" name="action" value="submit">제출</button>
<button type="submit" class="erp-btn erp-btn-outline" name="action"
value="{{ 'save' if mode == 'edit' else 'draft' }}">작성중 저장</button>
{% if mode == 'edit' %}
<a class="erp-btn erp-btn-outline" href="/vacation/{{ req.id }}">취소</a>
{% else %}
<a class="erp-btn erp-btn-outline" href="/vacation/">취소</a>
{% endif %}
</div>
</form>
</section>
{% endblock %}
{% block scripts %}<script src="/static/vacation.js?v=20260530i" defer></script>{% endblock %}
@@ -0,0 +1,128 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
{% block content %}
<section class="vac">
<!-- 페이지 액션 -->
<div class="vac-actions">
<div class="vac-actions-main">
<a class="erp-btn erp-btn-primary" href="/vacation/new">+ 휴가 신청</a>
{% if is_approver %}
<a class="erp-btn erp-btn-outline" href="/vacation/pending">승인 대기
{% if pending_count %}<span class="erp-badge erp-badge-inverse vac-mini">{{ pending_count }}</span>{% endif %}
</a>
{% endif %}
<a class="erp-btn erp-btn-outline" href="/vacation/export.xlsx?year={{ year }}&month={{ month }}">엑셀</a>
{% if is_admin %}
<a class="erp-btn erp-btn-outline" href="/vacation/settings?year={{ year }}">설정</a>
{% endif %}
</div>
<div class="vac-balance">
<span class="vac-bal-item"><strong>{{ balance.total_days }}</strong></span>
<span class="vac-bal-sep">·</span>
<span class="vac-bal-item">사용 <strong>{{ balance.used_days }}</strong></span>
<span class="vac-bal-sep">·</span>
<span class="vac-bal-item vac-bal-remain">잔여 <strong>{{ balance.remaining_days }}</strong></span>
<span class="erp-muted vac-bal-year">{{ year }}년</span>
</div>
</div>
<div class="vac-layout">
<!-- ── 왼쪽: 월간 달력 ── -->
<div class="erp-card vac-cal-card">
<div class="vac-cal-head">
<a class="erp-btn erp-btn-outline vac-nav-btn"
href="/vacation/?year={{ prev_y }}&month={{ prev_m }}"></a>
<h2 class="vac-cal-title">{{ year }}년 {{ month }}월</h2>
<a class="erp-btn erp-btn-outline vac-nav-btn"
href="/vacation/?year={{ next_y }}&month={{ next_m }}"></a>
<a class="erp-btn erp-btn-outline vac-today-btn" href="/vacation/">오늘</a>
</div>
<div class="vac-cal">
<div class="vac-wd-row">
{% for wd in weekdays %}
<div class="vac-wd {% if loop.index0 == 0 %}vac-red{% elif loop.index0 == 6 %}vac-blue{% endif %}">{{ wd }}</div>
{% endfor %}
</div>
{% for week in weeks %}
<div class="vac-week">
<div class="vac-week-days">
{% for cell in week.days %}
<a class="vac-day
{% if not cell.in_month %}vac-out{% endif %}
{% if cell.is_today %}vac-today{% endif %}
{% if cell.is_selected %}vac-selected{% endif %}"
href="/vacation/?year={{ year }}&month={{ month }}&date={{ cell.date }}"
{% if cell.holiday_name %}title="{{ cell.holiday_name }}"{% endif %}>
<span class="vac-day-num
{% if cell.is_sunday or cell.is_holiday %}vac-red{% elif cell.is_saturday %}vac-blue{% endif %}">{{ cell.day }}</span>
</a>
{% endfor %}
</div>
<div class="vac-week-bars">
{% for bar in week.bars %}
<a class="vac-bar {{ bar.status_class }}
{% if bar.continues_left %}vac-bar-l{% endif %}
{% if bar.continues_right %}vac-bar-r{% endif %}"
style="grid-column: {{ bar.start_col + 1 }} / span {{ bar.span }}; grid-row: {{ bar.lane + 1 }};"
href="/vacation/{{ bar.id }}"
title="{{ bar.label }} ({{ bar.days }}일 · {{ bar.status }})">
<span class="vac-bar-label">{{ bar.label }}</span>
</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<div class="vac-legend">
<span class="vac-leg"><i class="vac-dot vac-bar-submit"></i>제출</span>
<span class="vac-leg"><i class="vac-dot vac-bar-approve"></i>승인</span>
<span class="vac-leg"><i class="vac-dot vac-bar-reject"></i>반려</span>
<span class="vac-leg"><i class="vac-dot vac-bar-cancel"></i>취소</span>
</div>
</div>
<!-- ── 오른쪽: 선택일 휴가 리스트 ── -->
<div class="erp-card vac-list-card">
<div class="vac-list-head">
<h2>{{ selected_date }}</h2>
<span class="erp-muted">{{ sel_requests|length }}건</span>
</div>
{% if sel_requests %}
<ul class="vac-list">
{% for r in sel_requests %}
<li class="vac-list-item">
<a href="/vacation/{{ r.id }}" class="vac-list-link">
<div class="vac-list-top">
<strong>{{ r.owner_name or r.owner }}</strong>
{% set badge = 'erp-badge-neutral' %}
{% if r.status == '승인' %}{% set badge = 'erp-badge-success' %}
{% elif r.status == '제출' %}{% set badge = 'erp-badge-inverse' %}
{% elif r.status == '반려' %}{% set badge = 'erp-badge-danger' %}{% endif %}
<span class="erp-badge {{ badge }}">{{ r.status }}</span>
</div>
<div class="vac-list-meta erp-muted">
{{ r.vacation_type }} · {{ r.days }}일 ·
{{ r.start_date }}{% if r.end_date != r.start_date %} ~ {{ r.end_date }}{% endif %}
</div>
{% if r.reason %}<div class="vac-list-reason erp-muted">{{ r.reason }}</div>{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="erp-muted vac-empty">선택한 날짜의 휴가가 없습니다.
<a href="/vacation/new">휴가 신청</a></p>
{% endif %}
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,47 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
{% block content %}
<section class="vac">
<div class="erp-card">
{% if items %}
<div class="erp-table-wrap">
<table class="erp-table vac-pending-table">
<thead>
<tr>
<th>신청자</th><th>휴가종류</th><th>기간</th><th>일수</th>
<th>사유</th><th class="vac-act-col">동작</th>
</tr>
</thead>
<tbody>
{% for r in items %}
<tr>
<td><a href="/vacation/{{ r.id }}">{{ r.owner_name or r.owner }}</a></td>
<td>{{ r.vacation_type }}</td>
<td>{{ r.start_date }}{% if r.end_date != r.start_date %} ~ {{ r.end_date }}{% endif %}</td>
<td>{{ r.days }}</td>
<td class="vac-reason-cell erp-muted">{{ r.reason }}</td>
<td class="vac-act-col">
<div class="vac-pending-acts">
<form method="post" action="/vacation/{{ r.id }}/approve" class="vac-inline-form">
<button type="submit" class="erp-btn erp-btn-primary erp-btn-sm">승인</button>
</form>
<form method="post" action="/vacation/{{ r.id }}/reject" class="vac-reject-form">
<input class="erp-input erp-input-sm" type="text" name="reject_reason"
placeholder="반려 사유" required />
<button type="submit" class="erp-btn erp-btn-danger erp-btn-sm">반려</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="erp-muted">승인 대기 중인 휴가 신청이 없습니다.</p>
{% endif %}
</div>
</section>
{% endblock %}
@@ -0,0 +1,109 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
{% block content %}
<section class="vac">
<div class="vac-settings-head">
<form method="get" action="/vacation/settings" class="vac-year-form">
<label class="erp-field vac-year-field"><span>연도</span>
<input class="erp-input" type="number" name="year" value="{{ year }}" min="2020" max="2100" />
</label>
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
</form>
</div>
<div class="vac-settings-grid">
<!-- ── 공휴일 관리 ── -->
<div class="erp-card vac-set-card">
<div class="vac-card-head"><h2>공휴일 ({{ year }})</h2></div>
<form method="post" action="/vacation/settings/holidays" class="vac-holiday-form">
<label class="erp-field"><span>날짜 *</span>
<input class="erp-input" type="date" name="holiday_date" required value="{{ year }}-01-01" />
</label>
<label class="erp-field"><span>이름 *</span>
<input class="erp-input" type="text" name="name" required placeholder="예: 신정" />
</label>
<label class="erp-field"><span>종류</span>
<select class="erp-select" name="kind">
<option value="public">공휴일</option>
<option value="lunar">음력</option>
<option value="substitute">대체</option>
<option value="company">회사지정</option>
</select>
</label>
<label class="erp-check"><input type="checkbox" name="is_red" checked /> 빨강(달력 표시)</label>
<button type="submit" class="erp-btn erp-btn-primary">추가 / 수정</button>
</form>
<div class="erp-table-wrap vac-set-scroll">
<table class="erp-table">
<thead><tr><th>날짜</th><th>이름</th><th>종류</th><th>빨강</th><th></th></tr></thead>
<tbody>
{% for h in holidays %}
<tr>
<td>{{ h.holiday_date }}</td>
<td>{{ h.name }}</td>
<td class="erp-muted">{{ h.kind }}</td>
<td>{% if h.is_red %}●{% else %}○{% endif %}</td>
<td>
<form method="post" action="/vacation/settings/holidays/{{ h.id }}/delete"
onsubmit="return confirm('{{ h.holiday_date }} {{ h.name }} 삭제?');">
<input type="hidden" name="year" value="{{ year }}" />
<button type="submit" class="erp-btn erp-btn-danger erp-btn-sm">삭제</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="erp-muted">등록된 공휴일이 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- ── 연차 잔여 관리 ── -->
<div class="erp-card vac-set-card">
<div class="vac-card-head"><h2>연차 잔여 ({{ year }})</h2></div>
<form method="post" action="/vacation/settings/balances" class="vac-balance-form">
<input type="hidden" name="year" value="{{ year }}" />
<label class="erp-field"><span>이메일 *</span>
<input class="erp-input" type="email" name="user_email" required
placeholder="user@dbxcorp.co.kr" />
</label>
<label class="erp-field"><span>연차 일수 *</span>
<input class="erp-input" type="number" name="total_days" step="0.5" min="0" required value="15" />
</label>
<label class="erp-field vac-full"><span>메모</span>
<input class="erp-input" type="text" name="memo" placeholder="입사일/비고 등" />
</label>
<button type="submit" class="erp-btn erp-btn-primary">설정</button>
</form>
<div class="erp-table-wrap vac-set-scroll">
<table class="erp-table">
<thead><tr><th>이메일</th><th>연차</th><th>사용</th><th>메모</th></tr></thead>
<tbody>
{% for b in balances %}
<tr>
<td>{{ b.user_email }}</td>
<td>{{ b.total_days }}</td>
<td class="erp-muted">{{ b.used_days }}</td>
<td class="erp-muted">{{ b.memo }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="erp-muted">설정된 연차가 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="erp-muted vac-set-note">사용 일수는 승인된 휴가 합계로 자동 계산됩니다.</p>
</div>
</div>
</section>
{% endblock %}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+307
View File
@@ -0,0 +1,307 @@
/* 쿠팡 밀크런 — DESIGN.md 토큰 준수 (흰 배경 / 검정·회색 / 10·14px radius / 카드 16px) */
.cpg { display: flex; flex-direction: column; gap: 16px; }
.cpg-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
/* 맨 오른쪽으로 밀기 (수정 버튼) */
.cpg-push-right { margin-left: auto; }
/* 달력 상단 설정 버튼 그룹 — 우측 정렬 */
.cpg-settings-btns { margin-left: auto; display: inline-flex; gap: 8px; flex-wrap: wrap; }
/* 액션바를 달력 레이아웃(좌:달력 1fr / 우:리스트 320)과 같은 그리드로
→ 설정 버튼이 달력 컬럼 오른쪽 끝에 정렬 */
.cpg-actions-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
margin-bottom: 16px;
}
.cpg-actions-main { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
@media (max-width: 980px) {
.cpg-actions-grid { grid-template-columns: 1fr; }
.cpg-actions-spacer { display: none; }
}
/* ── 레이아웃: 왼쪽 큰 달력 + 오른쪽 리스트 ── */
.cpg-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
align-items: stretch;
flex: 1 1 auto; min-height: 0; /* 남은 높이 채움 → 문서 스크롤 방지 */
}
@media (max-width: 980px) { .cpg-layout { grid-template-columns: 1fr; } }
.cpg-cal-card, .cpg-list-card { padding: 16px; }
.cpg-cal-card { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.cpg-list-card { min-height: 0; overflow-y: auto; }
.cpg-cal-head {
display: flex; align-items: center; gap: 10px;
margin-bottom: 12px; flex-shrink: 0;
}
.cpg-cal-title { font-size: 18pt; font-weight: 600; letter-spacing: -0.45px; margin: 0; }
.cpg-nav-btn { min-width: 36px; padding: 4px 10px; font-size: 18pt; line-height: 1; }
.cpg-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: auto repeat(6, 1fr); /* 요일행 auto + 6주 균등 분할 */
gap: 6px;
flex: 1 1 auto; min-height: 0;
}
.cpg-cal-wd {
text-align: center; font-size: 15px; font-weight: 600;
color: var(--color-midtone-gray); padding: 6px 0;
}
.cpg-sun { color: var(--color-callout-red); }
.cpg-sat { color: #2b5bc2; }
.cpg-cal-cell {
display: flex; flex-direction: column; gap: 4px;
min-height: 0; padding: 8px; overflow: hidden;
border: 1px solid var(--color-subtle-ash);
border-radius: 10px;
background: #fff;
transition: border-color .12s, box-shadow .12s;
}
.cpg-cal-cell:hover { border-color: var(--color-midtone-gray); }
.cpg-out { background: var(--color-ghost-gray); }
.cpg-out .cpg-cal-day { color: var(--color-midtone-gray); }
.cpg-today { border-color: var(--color-deep-black); }
.cpg-selected { box-shadow: 0 0 0 2px var(--color-deep-black); border-color: var(--color-deep-black); }
.cpg-cal-day { font-size: 17px; font-weight: 600; }
/* 일요일/공휴일 빨강, 토요일 파랑 (당월 셀 우선, 전후월은 옅게) */
.cpg-red .cpg-cal-day { color: var(--color-callout-red); }
.cpg-blue .cpg-cal-day { color: #2b5bc2; }
.cpg-out.cpg-red .cpg-cal-day { color: #e3a59b; }
.cpg-out.cpg-blue .cpg-cal-day { color: #9db4dd; }
.cpg-cal-badges { display: flex; flex-direction: column; gap: 3px; }
.cpg-mini { font-size: 12px; padding: 2px 7px; align-self: flex-start; }
/* ── 오른쪽 리스트 ── */
.cpg-list-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }
.cpg-list-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
.cpg-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.cpg-list-item { border: 1px solid var(--color-subtle-ash); border-radius: 10px; }
.cpg-list-link { display: block; padding: 10px 12px; }
.cpg-list-link:hover { background: var(--color-ghost-gray); border-radius: 10px; }
.cpg-list-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.cpg-list-meta { font-size: 12px; margin-top: 4px; }
.cpg-empty { padding: 16px 0; }
/* 선택일 출고 hover 툴팁 (마우스 따라다님) */
.cpg-hover-tip {
position: fixed; z-index: 60; pointer-events: none;
background: var(--color-deep-black); color: #fff;
border-radius: 10px; padding: 8px 10px;
min-width: 160px; max-width: 280px;
box-shadow: 0 6px 20px rgba(0,0,0,.22);
font-size: 12px;
}
.cpg-tip-row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
.cpg-tip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cpg-tip-qty { font-weight: 600; flex: 0 0 auto; }
.cpg-tip-empty { color: #d4d4d4; }
/* ── 폼 ── */
.cpg-form-card { padding: 16px; margin-bottom: 16px; }
/* 공통 헤더(좌) + 품목 라인(우) 2열 */
/* form 이 .cpg(남은 높이) 안에서 세로로 채우도록 */
.cpg > form { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.cpg-form-2col {
display: flex; flex-wrap: wrap; gap: 16px; align-items: stretch;
flex: 1 1 auto; min-height: 0;
}
/* 공통 헤더: 너비 450 고정 */
.cpg-form-head { flex: 0 0 450px; width: 450px; min-width: 0; margin-bottom: 0; align-self: flex-start; }
/* 품목 라인: 너비 900 고정, 높이는 가용 영역 채움, 내부 스크롤 */
.cpg-form-lines {
flex: 0 0 900px; width: 900px; height: 100%; min-width: 0; margin-bottom: 0;
display: flex; flex-direction: column; overflow: hidden;
}
@media (max-width: 1400px) {
.cpg-form-lines { flex-basis: auto; width: 100%; }
}
@media (max-width: 940px) {
.cpg-form-head { flex-basis: 100%; width: 100%; }
}
/* 라인 테이블 영역만 스크롤 */
.cpg-lines-scroll { flex: 1 1 auto; min-height: 0; overflow: auto; }
/* 제목행: 제목 + 설명 + (우측) 추가/삭제 버튼 */
.cpg-lines-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.cpg-lines-btns { margin-left: auto; display: flex; gap: 8px; flex: 0 0 auto; }
/* 저장/취소 — 공통 헤더 카드 하단에 위치(우측 라인 수와 무관) */
.cpg-form-actions { margin-top: 16px; }
.cpg-card-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.cpg-card-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
.cpg-header-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 185px);
gap: 12px 16px;
}
/* 작성일·출고일·센터입고일·입고센터·출고방식·작업자 입력 185px 고정
(.cpg .erp-field > .erp-input width:100% 보다 특이도 높게) */
.cpg .cpg-header-grid .erp-field > .erp-input,
.cpg .cpg-header-grid .erp-field > .erp-select {
width: 185px; max-width: 185px; min-width: 185px; box-sizing: border-box;
}
.cpg-rule-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px 16px; align-items: end;
}
/* 그리드 항목 겹침 방지: 칸이 줄어들 때 내용이 밖으로 넘치지 않게 */
.cpg-header-grid .erp-field,
.cpg-rule-grid .erp-field { min-width: 0; }
.cpg .erp-field { display: flex; flex-direction: column; gap: 4px; margin: 0; }
.cpg .erp-field > span { font-size: 12px; color: var(--color-midtone-gray); }
/* 입력란이 칸 너비를 넘지 않도록 (date/select 포함) */
.cpg .erp-field > .erp-input,
.cpg .erp-field > .erp-select,
.cpg .erp-field > textarea.erp-input { width: 100%; max-width: 100%; box-sizing: border-box; }
.cpg-full { grid-column: 1 / -1; }
/* 검정 배경 버튼 글자는 항상 흰색 (안전 보강) */
.cpg .erp-btn-primary, .cpg .erp-btn-primary:visited { color: var(--color-canvas-white); }
.cpg-inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; }
.cpg-row-actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* ── 입고센터 관리 (좌: 추가 / 우: 목록) ── */
.cpg-center-layout {
display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start;
}
.cpg-center-add {
width: 360px; flex: 0 0 auto;
padding: 16px;
}
.cpg-center-listcard {
flex: 1 1 480px; min-width: 0;
height: 900px;
display: flex; flex-direction: column; overflow: hidden;
}
@media (max-width: 820px) {
.cpg-center-add, .cpg-center-listcard { width: 100%; flex-basis: 100%; }
}
.cpg-center-add-title { font-size: 15px; font-weight: 600; margin: 0 0 8px; }
.cpg-btn-sm { padding: 3px 8px; font-size: 12px; }
/* 목록: 카드 높이 채우고 내부 스크롤 */
.cpg-center-list {
flex: 1 1 auto; min-height: 0; overflow-y: auto;
display: flex; flex-direction: column; gap: 6px;
padding-right: 4px;
}
.cpg-center-row {
display: flex; align-items: center; gap: 8px;
padding: 5px 8px;
border: 1px solid var(--color-subtle-ash); border-radius: 10px;
min-width: 0;
}
.cpg-center-row.is-inactive { opacity: .55; }
.cpg-center-edit { display: flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
.cpg-center-name { flex: 1 1 auto; min-width: 0; }
.cpg-center-sort { width: 56px; flex: 0 0 auto; }
.cpg-center-state { display: inline-flex; gap: 4px; flex: 0 0 auto; }
.cpg-center-act { display: inline-flex; gap: 4px; flex: 0 0 auto; }
/* ── 라인 테이블 ── */
.cpg-lines td { vertical-align: middle; }
.cpg-lines th.cpg-check-col,
.cpg-lines td.cpg-check-col {
width: 36px; text-align: center;
padding-left: 0; padding-right: 0; vertical-align: middle;
}
.cpg-check-col input { display: block; margin: 0 auto; cursor: pointer; }
.cpg-lines .cpg-name-sel { width: 100%; min-width: 160px; }
/* 폭 고정: 제품코드 140px, 수량·입수량 60px */
.cpg-lines .cpg-code { width: 140px; min-width: 140px; box-sizing: border-box; }
.cpg-lines .cpg-qty,
.cpg-lines .cpg-upb { width: 60px; min-width: 60px; box-sizing: border-box; }
.cpg-lines .cpg-memo { width: 100%; min-width: 120px; box-sizing: border-box; }
.cpg-line-calc { font-size: 13px; color: var(--color-midtone-gray); white-space: nowrap; }
.cpg-line-calc.cpg-warn { color: var(--color-callout-red); }
.cpg-line-del { cursor: pointer; }
/* ── 상세 ── */
.cpg-detail-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px; margin: 0;
}
.cpg-detail-grid dt { font-size: 12px; color: var(--color-midtone-gray); }
.cpg-detail-grid dd { margin: 2px 0 0; font-size: 14px; }
/* ── 설정(제품명) 2열 레이아웃 ── */
.cpg-prod-layout {
display: flex;
flex-wrap: wrap;
gap: 16px; align-items: flex-start;
}
/* 미라네 주방 상품: 폭 500 / 높이 900 고정, 내부 스크롤 */
.cpg-prod-left {
width: 500px; height: 900px;
display: flex; flex-direction: column;
overflow: hidden;
}
/* 등록된 제품명: 폭 600 / 높이 900 고정, 내부 스크롤 */
.cpg-prod-right {
width: 600px; height: 900px;
display: flex; flex-direction: column;
overflow: hidden;
}
@media (max-width: 1180px) {
.cpg-prod-left, .cpg-prod-right { width: 100%; }
}
.cpg-src-list {
flex: 1 1 auto; min-height: 0; overflow-y: auto;
border: 1px solid var(--color-subtle-ash); border-radius: 10px;
}
/* 등록 목록 테이블 스크롤 영역 (카드 높이에서 헤더 제외하고 채움) */
.cpg-reg-scroll {
flex: 1 1 auto; min-height: 0; overflow-y: auto;
}
.cpg-src-item {
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
padding: 8px 12px; cursor: pointer;
border-bottom: 1px solid var(--color-subtle-ash);
}
.cpg-src-item:last-child { border-bottom: 0; }
.cpg-src-item:hover { background: var(--color-ghost-gray); }
.cpg-src-name { font-size: 14px; font-weight: 500; }
.cpg-src-code { font-size: 12px; color: var(--color-midtone-gray); white-space: nowrap; }
/* 선택됨: 검정 테두리 강조 */
.cpg-src-item.is-selected { box-shadow: inset 0 0 0 2px var(--color-deep-black); }
/* 이미 등록됨: 진한 회색 배경 + 흰 글씨 */
.cpg-src-item.cpg-registered { background: #4b4b4b; }
.cpg-src-item.cpg-registered .cpg-src-name { color: #fff; }
.cpg-src-item.cpg-registered .cpg-src-code { color: #d4d4d4; }
.cpg-src-item.cpg-registered:hover { background: #3a3a3a; }
/* ── 박스 입수량 (좌: 추가/수정 480 / 우: 목록 900) ── */
.cpg-brule-layout { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; }
.cpg-brule-add { flex: 0 0 480px; width: 480px; min-width: 0; margin-bottom: 0; }
.cpg-brule-list { flex: 0 0 900px; width: 900px; min-width: 0; margin-bottom: 0; }
@media (max-width: 1420px) { .cpg-brule-list { flex-basis: auto; width: 100%; } }
@media (max-width: 540px) { .cpg-brule-add { flex-basis: 100%; width: 100%; } }
.cpg-brule-fields { display: flex; flex-direction: column; gap: 12px; }
.cpg-brule-row { display: flex; gap: 16px; align-items: flex-end; }
.cpg-brule-memo-field { flex: 1 1 auto; min-width: 0; }
/* 필드별 고정 폭 (erp.css min-width:240 오버라이드) */
.cpg .cpg-brule-fields .cpg-brule-name { width: 180px; min-width: 180px; max-width: 180px; box-sizing: border-box; }
.cpg .cpg-brule-fields .cpg-brule-code { width: 100px; min-width: 100px; max-width: 100px; box-sizing: border-box; }
/* 박스이름 폭을 제품명(180)과 동일하게 → 박스당 입수량이 제품코드와 세로 정렬 */
.cpg .cpg-brule-fields .cpg-brule-box { width: 180px; min-width: 180px; max-width: 180px; box-sizing: border-box; }
/* 박스당 입수량 60px + "개" */
.cpg-upb-wrap { display: inline-flex; align-items: center; gap: 4px; }
.cpg .cpg-brule-fields .cpg-upb-wrap > .cpg-brule-upb {
width: 60px; min-width: 60px; max-width: 60px; box-sizing: border-box;
}
.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; }
+198
View File
@@ -0,0 +1,198 @@
/* .
제품명 드롭다운(설정에서 등록한 카탈로그) 선택 제품코드 자동 입력.
수량 입력 박스 미리보기(서버가 저장 store.compute_boxes 재계산). */
(function () {
"use strict";
var body = document.getElementById("cpg-lines-body");
var form = document.getElementById("cpg-form");
if (!body || !form) return;
// 입수량 규칙: product_code -> units_per_box
var ruleMap = {};
try {
var rules = JSON.parse(document.getElementById("cpg-box-rules").textContent || "[]");
rules.forEach(function (r) { if (r.active !== false) ruleMap[r.product_code] = r.units_per_box; });
} catch (e) {}
// 제품 카탈로그: [{product_code, product_name}]
var products = [];
try { products = JSON.parse(document.getElementById("cpg-products").textContent || "[]"); } catch (e) {}
var nameByCode = {};
products.forEach(function (p) { nameByCode[p.product_code] = p.product_name; });
var initLines = [];
try { initLines = JSON.parse(document.getElementById("cpg-init-lines").textContent || "[]"); } catch (e) {}
function ceilDiv(a, b) { return Math.ceil(a / b); }
function recalc(row) {
var qty = parseInt(row.querySelector(".cpg-qty").value, 10) || 0;
var upb = parseInt(row.querySelector(".cpg-upb").value, 10);
var cell = row.querySelector(".cpg-line-calc");
if (!upb || upb <= 0) { cell.textContent = "미설정"; cell.classList.add("cpg-warn"); return; }
cell.classList.remove("cpg-warn");
if (qty <= 0) { cell.textContent = "—"; return; }
var boxes = ceilDiv(qty, upb), rem = qty % upb;
cell.textContent = boxes + "박스" + (rem ? " +" + rem : " (딱맞음)");
}
function buildNameSelect(selectedCode) {
var sel = document.createElement("select");
sel.className = "erp-select cpg-name-sel";
var opt0 = document.createElement("option");
opt0.value = ""; opt0.textContent = "— 제품명 선택 —";
sel.appendChild(opt0);
var found = false;
products.forEach(function (p) {
var o = document.createElement("option");
o.value = p.product_code;
o.textContent = p.product_name;
o.setAttribute("data-name", p.product_name);
if (p.product_code === selectedCode) { o.selected = true; found = true; }
sel.appendChild(o);
});
// 카탈로그에 없는 기존 라인 코드 → 임시 옵션으로 표시
if (selectedCode && !found) {
var o = document.createElement("option");
o.value = selectedCode;
o.textContent = (nameByCode[selectedCode] || selectedCode) + " (미등록)";
o.setAttribute("data-name", nameByCode[selectedCode] || selectedCode);
o.selected = true;
sel.appendChild(o);
}
return sel;
}
function makeRow(data) {
data = data || {};
var tr = document.createElement("tr");
// 컬럼: 체크 / 제품명(select) / 제품코드 / 수량 / 입수량 / 박스계산 / 라인메모
tr.innerHTML =
'<td class="cpg-check-col"><input type="checkbox" class="cpg-row-check" /></td>' +
'<td class="cpg-cell-name"></td>' +
'<td><input class="erp-input cpg-code" type="text" placeholder="제품코드" /></td>' +
'<td><input class="erp-input cpg-qty" type="number" min="1" /></td>' +
'<td><input class="erp-input cpg-upb" type="number" min="1" placeholder="입수량" /></td>' +
'<td><span class="cpg-line-calc">—</span></td>' +
'<td><input class="erp-input cpg-memo" type="text" /></td>';
var code = data.product_code || "";
var nameSel = buildNameSelect(code);
tr.querySelector(".cpg-cell-name").appendChild(nameSel);
var codeInput = tr.querySelector(".cpg-code");
codeInput.value = code;
tr.querySelector(".cpg-qty").value = data.quantity || "";
var upb = data.units_per_box;
if (upb == null && code && ruleMap[code] != null) upb = ruleMap[code];
tr.querySelector(".cpg-upb").value = (upb != null ? upb : "");
// 제품명 선택 → 코드 자동 입력 + 입수량 자동
nameSel.addEventListener("change", function () {
var c = nameSel.value;
codeInput.value = c;
if (c && ruleMap[c] != null) tr.querySelector(".cpg-upb").value = ruleMap[c];
recalc(tr);
});
// 코드 직접 입력 시 입수량 규칙 자동
codeInput.addEventListener("input", function () {
var c = codeInput.value.trim();
if (c && ruleMap[c] != null && !tr.querySelector(".cpg-upb").value) {
tr.querySelector(".cpg-upb").value = ruleMap[c];
}
});
tr.querySelector(".cpg-qty").addEventListener("input", function () { recalc(tr); });
tr.querySelector(".cpg-upb").addEventListener("input", function () { recalc(tr); });
body.appendChild(tr);
recalc(tr);
syncCheckAll();
return tr;
}
// 전체 선택 체크박스 상태 동기화
var checkAll = document.getElementById("cpg-check-all");
function syncCheckAll() {
if (!checkAll) return;
var checks = body.querySelectorAll(".cpg-row-check");
var total = checks.length;
var on = 0;
Array.prototype.forEach.call(checks, function (c) { if (c.checked) on++; });
checkAll.checked = total > 0 && on === total;
checkAll.indeterminate = on > 0 && on < total;
}
if (checkAll) {
checkAll.addEventListener("change", function () {
Array.prototype.forEach.call(body.querySelectorAll(".cpg-row-check"), function (c) {
c.checked = checkAll.checked;
});
});
}
body.addEventListener("change", function (e) {
if (e.target && e.target.classList.contains("cpg-row-check")) syncCheckAll();
});
// 초기 라인
if (initLines.length) { initLines.forEach(makeRow); } else { makeRow(); }
document.getElementById("cpg-add-line").addEventListener("click", function () { makeRow(); });
// 선택 라인 삭제 (체크된 행 삭제, 최소 1줄 유지)
var delBtn = document.getElementById("cpg-del-line");
if (delBtn) {
delBtn.addEventListener("click", function () {
var checked = body.querySelectorAll(".cpg-row-check:checked");
if (!checked.length) { alert("삭제할 라인을 선택하세요."); return; }
Array.prototype.forEach.call(checked, function (c) {
var tr = c.closest("tr");
if (tr) tr.remove();
});
if (!body.querySelectorAll("tr").length) makeRow(); // 최소 1줄
if (checkAll) { checkAll.checked = false; checkAll.indeterminate = false; }
syncCheckAll();
});
}
// 제출: 라인 직렬화
form.addEventListener("submit", function (e) {
var lines = [];
Array.prototype.forEach.call(body.querySelectorAll("tr"), function (tr) {
var code = tr.querySelector(".cpg-code").value.trim();
var qty = parseInt(tr.querySelector(".cpg-qty").value, 10) || 0;
if (!code || qty <= 0) return;
var upb = parseInt(tr.querySelector(".cpg-upb").value, 10);
var sel = tr.querySelector(".cpg-name-sel");
var name = "";
if (sel && sel.selectedIndex >= 0) {
var opt = sel.options[sel.selectedIndex];
name = opt ? (opt.getAttribute("data-name") || "") : "";
}
if (!name) name = nameByCode[code] || code;
lines.push({
product_code: code,
product_name_snapshot: name,
quantity: qty,
units_per_box: (upb > 0 ? upb : null),
memo: tr.querySelector(".cpg-memo").value.trim()
});
});
if (!lines.length) {
e.preventDefault();
alert("품목 라인을 최소 1개 입력하세요 (제품명/코드 + 수량).");
return;
}
document.getElementById("cpg-lines-json").value = JSON.stringify(lines);
});
// 입고센터 select → 스냅샷 hidden 동기화
var centerSel = document.getElementById("cpg-center-select");
var centerName = document.getElementById("cpg-center-name");
if (centerSel && centerName) {
function syncCenter() {
var opt = centerSel.options[centerSel.selectedIndex];
if (opt && opt.value) centerName.value = opt.getAttribute("data-name") || opt.text;
}
centerSel.addEventListener("change", syncCenter);
if (!centerName.value) syncCenter();
}
})();
+129
View File
@@ -0,0 +1,129 @@
/*
첨부 뷰어 모달 이미지 패닝 + 엑셀 시트
*/
.eav-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0, 0, 0, 0.62);
display: none;
align-items: stretch; justify-content: center;
padding: 24px;
}
.eav-overlay[data-open="true"] { display: flex; }
.eav-modal {
background: var(--color-canvas-white, #fff);
width: 100%;
max-width: 1280px;
border-radius: 14px;
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.35);
}
.eav-head {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
}
.eav-title { font-weight: 600; font-size: 14px; flex: 1; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.eav-toolbar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.eav-btn {
border: 1px solid var(--color-subtle-ash, #e5e5e5);
background: var(--color-canvas-white, #fff);
color: var(--color-rich-black, #0a0a0a);
padding: 5px 10px; font-size: 12px; border-radius: 8px; cursor: pointer;
font-family: inherit; text-decoration: none; display: inline-block;
}
.eav-btn:hover { background: var(--color-ghost-gray, #f2f2f2); }
.eav-btn.is-active { background: var(--color-deep-black, #000); color: #fff; border-color: #000; }
.eav-close {
width: 28px; height: 28px; border-radius: 8px;
display: inline-flex; align-items: center; justify-content: center;
border: 0; background: transparent; cursor: pointer; font-size: 18px;
}
.eav-close:hover { background: var(--color-ghost-gray, #f2f2f2); }
.eav-body {
display: grid; grid-template-columns: 220px 1fr; min-height: 0; height: 100%;
background: #1a1a1a;
}
.eav-list {
background: #fff; border-right: 1px solid var(--color-subtle-ash, #e5e5e5);
overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 4px;
}
.eav-item {
display: flex; align-items: center; gap: 8px;
padding: 8px; border-radius: 8px; cursor: pointer;
border: 1px solid transparent; background: transparent;
text-align: left; font-family: inherit; font-size: 12px;
}
.eav-item:hover { background: var(--color-ghost-gray, #f2f2f2); }
.eav-item.is-active { background: var(--color-ghost-gray, #f2f2f2); border-color: var(--color-subtle-ash, #e5e5e5); }
.eav-item-thumb {
width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0;
background: var(--color-ghost-gray, #f2f2f2);
display: inline-flex; align-items: center; justify-content: center;
font-size: 18px; color: var(--color-midtone-gray, #737373);
overflow: hidden;
}
.eav-item-thumb img { width: 100%; height: 100%; object-fit: cover; }
.eav-item-meta { flex: 1; min-width: 0; }
.eav-item-name { font-weight: 500; color: var(--color-rich-black, #0a0a0a);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
.eav-item-sub { font-size: 11px; color: var(--color-midtone-gray, #737373); display: block; }
/* 이미지 패닝 스테이지 */
.eav-canvas-wrap {
position: relative;
overflow: auto;
padding: 0;
background: #2a2a2a;
}
.eav-canvas-wrap.eav-pan { cursor: grab; }
.eav-canvas-wrap.eav-pan.is-panning { cursor: grabbing; }
.eav-img { display: block; user-select: none; -webkit-user-drag: none; margin: 0 auto; }
/* 엑셀/시트 */
.eav-sheet-wrap {
background: #fff;
width: 100%; height: 100%;
display: grid; grid-template-rows: auto 1fr;
}
.eav-sheet-tabs {
display: flex; gap: 4px; padding: 8px;
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
background: var(--color-ghost-gray, #f2f2f2);
overflow-x: auto;
}
.eav-sheet-body {
overflow: auto; padding: 12px;
}
.eav-sheet-body table {
border-collapse: collapse;
font-family: 'Geist Mono', Menlo, monospace; font-size: 12px;
}
.eav-sheet-body td, .eav-sheet-body th {
border: 1px solid var(--color-subtle-ash, #e5e5e5);
padding: 4px 8px;
min-width: 60px;
white-space: nowrap;
}
.eav-sheet-body th { background: var(--color-ghost-gray, #f2f2f2); font-weight: 600; }
.eav-sheet-loading { color: #fff; text-align: center; padding: 40px; }
.eav-fallback {
color: #fff; text-align: center; padding: 40px;
}
.eav-fallback a {
display: inline-block; margin-top: 12px;
background: #fff; color: #000; padding: 8px 16px; border-radius: 8px;
}
@media (max-width: 720px) {
.eav-body { grid-template-columns: 1fr; grid-template-rows: 100px 1fr; }
.eav-list { flex-direction: row; overflow-x: auto; }
.eav-item { flex-shrink: 0; min-width: 180px; }
}
+311
View File
@@ -0,0 +1,311 @@
// ──────────────────────────────────────────────────────────
// ErpAttachViewer — 첨부 모달
// - 이미지: 마우스 드래그로 패닝 (이미지 크면 스크롤). 확대/축소/맞춤.
// - 엑셀 (xlsx/xls/csv): SheetJS 로 시트 표시 (CDN lazy load).
// - 기타: 다운로드 링크 표시.
// API: ErpAttachViewer.openFor(itemId, { title })
// ──────────────────────────────────────────────────────────
(function () {
if (window.ErpAttachViewer) return;
const IMG_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
const SHEET_EXTS = new Set(["xlsx", "xls", "xlsm", "csv", "ods"]);
const XLSX_CDN = "https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js";
let overlay = null;
let state = {
items: [],
currentIdx: -1,
title: "",
zoom: 1, // 이미지 줌 (1 = 100%)
// 패닝
panning: false,
panStartX: 0, panStartY: 0,
scrollStartLeft: 0, scrollStartTop: 0,
};
let xlsxLoading = null;
function ensureOverlay() {
if (overlay) return overlay;
overlay = document.createElement("div");
overlay.className = "eav-overlay";
overlay.innerHTML = `
<div class="eav-modal" role="dialog" aria-modal="true">
<div class="eav-head">
<div class="eav-title" data-role="title">첨부 보기</div>
<div class="eav-toolbar" data-role="toolbar"></div>
<button class="eav-close" data-role="close" aria-label="닫기"></button>
</div>
<div class="eav-body">
<div class="eav-list" data-role="list"></div>
<div class="eav-canvas-wrap" data-role="stage"></div>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-role="close"]').addEventListener("click", close);
document.addEventListener("keydown", (e) => {
if (overlay.dataset.open === "true" && e.key === "Escape") close();
});
return overlay;
}
function loadXlsxLib() {
if (window.XLSX) return Promise.resolve(window.XLSX);
if (xlsxLoading) return xlsxLoading;
xlsxLoading = new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = XLSX_CDN;
s.onload = () => resolve(window.XLSX);
s.onerror = () => reject(new Error("XLSX 라이브러리 로드 실패"));
document.head.appendChild(s);
});
return xlsxLoading;
}
function buildToolbar() {
const tb = overlay.querySelector('[data-role="toolbar"]');
tb.innerHTML = "";
const cur = state.items[state.currentIdx];
if (!cur) return;
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
if (IMG_EXTS.has(ext)) {
[
{ label: "", title: "축소", fn: () => setZoom(state.zoom / 1.25) },
{ label: "100%", title: "원본", fn: () => setZoom(1) },
{ label: "", title: "확대", fn: () => setZoom(state.zoom * 1.25) },
{ label: "맞춤", title: "화면에 맞춤", fn: () => setZoom("fit") },
].forEach((b) => {
const btn = document.createElement("button");
btn.className = "eav-btn";
btn.textContent = b.label;
btn.title = b.title;
btn.onclick = b.fn;
tb.appendChild(btn);
});
}
const dl = document.createElement("a");
dl.className = "eav-btn";
dl.textContent = "다운로드";
dl.href = `/expense/api/attachments/${cur.id}`;
dl.setAttribute("download", cur.filename);
tb.appendChild(dl);
const open = document.createElement("a");
open.className = "eav-btn";
open.textContent = "새 창";
open.href = `/expense/api/attachments/${cur.id}`;
open.target = "_blank";
tb.appendChild(open);
}
function buildList() {
const list = overlay.querySelector('[data-role="list"]');
list.innerHTML = "";
state.items.forEach((a, i) => {
const item = document.createElement("button");
item.className = "eav-item" + (i === state.currentIdx ? " is-active" : "");
const ext = (a.filename.split(".").pop() || "").toLowerCase();
const isImg = IMG_EXTS.has(ext);
const isSheet = SHEET_EXTS.has(ext);
const icon = isImg ? `<img src="/expense/api/attachments/${a.id}" alt="" />`
: isSheet ? "📊" : "📄";
item.innerHTML = `
<span class="eav-item-thumb">${icon}</span>
<span class="eav-item-meta">
<span class="eav-item-name">${escapeHtml(a.filename)}</span>
<span class="eav-item-sub">${a.kind === "receipt" ? "영수증" : "기타파일"} · ${fmtSize(a.size_bytes)}</span>
</span>
`;
item.onclick = () => { state.currentIdx = i; state.zoom = 1; renderCurrent(); buildList(); buildToolbar(); };
list.appendChild(item);
});
}
function renderCurrent() {
const stage = overlay.querySelector('[data-role="stage"]');
stage.innerHTML = "";
stage.className = "eav-canvas-wrap";
const cur = state.items[state.currentIdx];
if (!cur) {
stage.innerHTML = `<div class="eav-fallback">첨부가 없습니다.</div>`;
return;
}
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
if (IMG_EXTS.has(ext)) return renderImage(stage, cur);
if (SHEET_EXTS.has(ext)) return renderSheet(stage, cur, ext);
return renderFallback(stage, cur);
}
function renderImage(stage, cur) {
stage.classList.add("eav-pan");
const img = document.createElement("img");
img.className = "eav-img";
img.alt = cur.filename;
img.src = `/expense/api/attachments/${cur.id}`;
img.draggable = false;
img.onload = () => { applyZoom(); };
img.onerror = () => { stage.innerHTML = `<div class="eav-fallback">이미지 로드 실패</div>`; };
stage.appendChild(img);
state.image = img;
// 마우스 드래그 패닝
stage.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
state.panning = true;
state.panStartX = e.clientX;
state.panStartY = e.clientY;
state.scrollStartLeft = stage.scrollLeft;
state.scrollStartTop = stage.scrollTop;
stage.setPointerCapture(e.pointerId);
stage.classList.add("is-panning");
});
stage.addEventListener("pointermove", (e) => {
if (!state.panning) return;
stage.scrollLeft = state.scrollStartLeft - (e.clientX - state.panStartX);
stage.scrollTop = state.scrollStartTop - (e.clientY - state.panStartY);
});
const endPan = (e) => {
if (!state.panning) return;
state.panning = false;
try { stage.releasePointerCapture(e.pointerId); } catch (_) {}
stage.classList.remove("is-panning");
};
stage.addEventListener("pointerup", endPan);
stage.addEventListener("pointercancel", endPan);
stage.addEventListener("pointerleave", endPan);
}
function applyZoom() {
if (!state.image) return;
const stage = overlay.querySelector('[data-role="stage"]');
const img = state.image;
if (state.zoom === "fit") {
img.style.maxWidth = "100%";
img.style.maxHeight = "calc(100vh - 200px)";
img.style.width = "";
img.style.height = "";
} else {
img.style.maxWidth = "none";
img.style.maxHeight = "none";
img.style.width = (img.naturalWidth * state.zoom) + "px";
img.style.height = "auto";
}
}
function setZoom(z) {
state.zoom = z;
applyZoom();
}
async function renderSheet(stage, cur, ext) {
stage.classList.remove("eav-pan");
stage.innerHTML = `<div class="eav-sheet-loading">시트 로드 중…</div>`;
try {
const XLSX = await loadXlsxLib();
const res = await fetch(`/expense/api/attachments/${cur.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = await res.arrayBuffer();
const wb = XLSX.read(buf, { type: "array" });
stage.innerHTML = "";
const sheetWrap = document.createElement("div");
sheetWrap.className = "eav-sheet-wrap";
const tabs = document.createElement("div");
tabs.className = "eav-sheet-tabs";
const body = document.createElement("div");
body.className = "eav-sheet-body";
function showSheet(name) {
const ws = wb.Sheets[name];
const html = XLSX.utils.sheet_to_html(ws, { editable: false });
body.innerHTML = html;
tabs.querySelectorAll("button").forEach((b) => {
b.classList.toggle("is-active", b.dataset.sheet === name);
});
}
wb.SheetNames.forEach((name) => {
const b = document.createElement("button");
b.className = "eav-btn";
b.dataset.sheet = name;
b.textContent = name;
b.onclick = () => showSheet(name);
tabs.appendChild(b);
});
sheetWrap.appendChild(tabs);
sheetWrap.appendChild(body);
stage.appendChild(sheetWrap);
showSheet(wb.SheetNames[0]);
} catch (err) {
stage.innerHTML = `<div class="eav-fallback">시트 로드 실패: ${escapeHtml(err.message || String(err))}
<br/><a href="/expense/api/attachments/${cur.id}" target="_blank">원본 다운로드</a></div>`;
}
}
function renderFallback(stage, cur) {
stage.classList.remove("eav-pan");
stage.innerHTML = `
<div class="eav-fallback">
미리보기를 지원하지 않는 파일입니다.<br/>
<a href="/expense/api/attachments/${cur.id}" target="_blank">다운로드 / 열기</a>
</div>
`;
}
function fmtSize(b) {
b = Number(b || 0);
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
return `${(b / 1024 / 1024).toFixed(2)} MB`;
}
function escapeHtml(s) {
return String(s || "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])
);
}
function open(items, opts) {
ensureOverlay();
state.items = items || [];
state.currentIdx = state.items.length ? 0 : -1;
state.zoom = 1;
state.title = (opts && opts.title) || "첨부 보기";
overlay.querySelector('[data-role="title"]').textContent = state.title;
overlay.dataset.open = "true";
buildList();
buildToolbar();
renderCurrent();
}
function close() {
if (!overlay) return;
overlay.dataset.open = "false";
state.items = [];
state.currentIdx = -1;
state.image = null;
}
async function openFor(itemId, opts) {
try {
const res = await fetch(`/expense/api/items/${itemId}/attachments`);
if (!res.ok) throw new Error((await res.json()).detail || res.status);
const { attachments } = await res.json();
if (!attachments || !attachments.length) {
alert("첨부 파일이 없습니다.");
return;
}
open(attachments, opts);
} catch (err) {
alert(`첨부 로드 실패: ${err.message || err}`);
}
}
window.ErpAttachViewer = { open, openFor, close };
})();
+305
View File
@@ -0,0 +1,305 @@
/*
ERP Shell 좌측 사이드바 + 우측 콘텐츠 레이아웃
erp.css 토큰을 그대로 사용한다. 같이 로드 필수.
*/
:root {
--erp-sidebar-w: 240px;
--erp-sidebar-w-collapsed: 64px;
--erp-topbar-h: 56px;
}
/* 페이지 전체 컨테이너
뷰포트(: 1883×938) 고정 문서 전체 세로 스크롤 제거.
넘치는 콘텐츠는 .erp-page 내부에서만 처리한다(CORM/Order 별도 창이라 무관). */
body.erp-app-body { background: var(--color-canvas-white); height: 100vh; overflow: hidden; }
.erp-app {
display: grid;
grid-template-columns: var(--erp-sidebar-w) 1fr;
height: 100vh;
transition: grid-template-columns .18s ease;
}
.erp-app:has(.erp-sidebar[data-collapsed="true"]) {
grid-template-columns: var(--erp-sidebar-w-collapsed) 1fr;
}
/* ── 사이드바 ── */
.erp-sidebar {
position: sticky; top: 0;
height: 100vh;
border-right: 1px solid var(--color-subtle-ash);
background: var(--color-canvas-white);
display: flex; flex-direction: column;
overflow: hidden;
z-index: 40;
}
.erp-sidebar-head {
height: var(--erp-topbar-h);
display: flex; align-items: center; justify-content: space-between;
padding: 0 var(--sp-12) 0 var(--sp-16);
border-bottom: 1px solid var(--color-subtle-ash);
flex-shrink: 0;
}
.erp-sidebar-brand {
display: inline-flex; align-items: center; gap: var(--sp-8);
color: var(--color-deep-black);
}
.erp-sidebar-logo { height: 22px; width: auto; display: block; }
.erp-sidebar-system {
font-weight: 600; font-size: 14px; letter-spacing: -0.2px;
}
.erp-sidebar[data-collapsed="true"] .erp-sidebar-system { display: none; }
.erp-sidebar-toggle {
width: 28px; height: 28px; border-radius: 8px;
display: inline-flex; align-items: center; justify-content: center;
background: transparent; border: 1px solid transparent; cursor: pointer;
color: var(--color-midtone-gray);
}
.erp-sidebar-toggle:hover { background: var(--color-ghost-gray); color: var(--color-deep-black); }
.erp-sidebar[data-collapsed="true"] .erp-sidebar-toggle svg { transform: rotate(180deg); }
.erp-sidebar-nav {
flex: 1; overflow-y: auto;
padding: var(--sp-12) var(--sp-8);
display: flex; flex-direction: column; gap: 2px;
}
.erp-sidebar-group {
font-size: 12px; font-weight: 600;
color: var(--color-midtone-gray);
text-transform: uppercase; letter-spacing: 0.06em;
padding: var(--sp-12) var(--sp-12) var(--sp-6);
}
.erp-sidebar[data-collapsed="true"] .erp-sidebar-group { opacity: 0; height: 0; padding: 0; pointer-events: none; }
.erp-sidebar-item {
display: flex; align-items: center; gap: 12px;
padding: 11px var(--sp-12);
border-radius: 10px;
color: var(--color-rich-black);
font-size: 16px; font-weight: 500;
cursor: pointer;
transition: background .12s ease, color .12s ease;
border: 1px solid transparent;
text-decoration: none;
white-space: nowrap;
}
/* 실행중(is-active) 메뉴는 hover 시 변화 없음. 그 외 메뉴만 진한 회색. */
.erp-sidebar-item:hover:not(.is-disabled):not(.is-active) { background: #d9d9d9; }
.erp-sidebar-item.is-active {
background: var(--color-deep-black);
color: var(--color-canvas-white);
}
.erp-sidebar-item.is-disabled {
color: var(--color-midtone-gray);
cursor: not-allowed;
}
.erp-sidebar-icon {
width: 22px; height: 22px;
display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.erp-sidebar-icon svg { width: 21px; height: 21px; }
.erp-sidebar-label { flex: 1; }
.erp-sidebar-badge {
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
padding: 2px 8px; border-radius: var(--r-badge);
font-size: 11px; font-weight: 500;
}
.erp-sidebar-item.is-active .erp-sidebar-badge {
background: rgba(255,255,255,0.18); color: var(--color-canvas-white);
}
.erp-sidebar-ext { color: var(--color-midtone-gray); display: inline-flex; }
.erp-sidebar[data-collapsed="true"] .erp-sidebar-badge,
.erp-sidebar[data-collapsed="true"] .erp-sidebar-ext {
display: none;
}
/* 접힌 상태: 아이콘 위 / 프로그램 이름 작은 글씨 아래 */
.erp-sidebar[data-collapsed="true"] .erp-sidebar-item {
flex-direction: column; gap: 3px; padding: 8px 2px;
justify-content: center; text-align: center;
}
.erp-sidebar[data-collapsed="true"] .erp-sidebar-label {
flex: none; font-size: 10px; line-height: 1.1; font-weight: 500;
white-space: normal; word-break: keep-all;
}
.erp-sidebar-foot {
padding: var(--sp-8);
border-top: 1px solid var(--color-subtle-ash);
display: flex; flex-direction: column; gap: 2px;
flex-shrink: 0;
}
/* ── 콘텐츠 영역 ── */
.erp-content { display: flex; flex-direction: column; min-width: 0; height: 100vh; min-height: 0; }
.erp-topbar {
height: var(--erp-topbar-h); flex-shrink: 0;
position: sticky; top: 0; z-index: 30;
background: var(--color-canvas-white);
border-bottom: 1px solid var(--color-subtle-ash);
display: flex; align-items: center; justify-content: space-between;
padding: 0 var(--sp-24);
gap: var(--sp-16);
}
.erp-topbar-left { display: flex; align-items: center; gap: var(--sp-12); min-width: 0; }
.erp-topbar-title h1 {
font-size: 16px; font-weight: 600; margin: 0; line-height: 1.2;
letter-spacing: -0.2px;
}
.erp-topbar-title p {
margin: 2px 0 0; color: var(--color-midtone-gray); font-size: 12px;
}
.erp-topbar-right { display: flex; align-items: center; gap: var(--sp-12); }
.erp-sidebar-mobile-toggle {
display: none;
width: 32px; height: 32px; border-radius: 8px;
align-items: center; justify-content: center;
background: transparent; border: 1px solid var(--color-subtle-ash); cursor: pointer;
color: var(--color-rich-black);
}
.erp-page {
padding: var(--sp-24);
max-width: 1550px; width: 100%; margin: 0 auto;
flex: 1 1 auto; min-height: 0;
overflow-y: auto; /* 넘치면 페이지 영역만 스크롤(문서 전체 X) */
display: flex; flex-direction: column;
}
/* 달력 등 "한 화면 채움" 페이지: 첫 섹션이 남은 높이를 모두 차지 */
.erp-page > .vac,
.erp-page > .cpg { flex: 1 1 auto; min-height: 0; }
/* ── 홈 대시보드 타일 ── */
.erp-hero { margin: var(--sp-8) 0 var(--sp-24); }
.erp-hero-title { font-size: 28px; font-weight: 600; margin: 0; letter-spacing: -0.6px; }
.erp-hero-sub { margin: 6px 0 0; color: var(--color-midtone-gray); font-size: 13px; }
.erp-home-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--sp-16);
}
.erp-tile {
border: 1px solid var(--color-subtle-ash);
border-radius: var(--r-card);
padding: var(--sp-20);
background: var(--color-canvas-white);
display: flex; flex-direction: column; gap: 8px;
}
.erp-tile-head {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;
}
.erp-tile-label {
font-size: 11px; color: var(--color-midtone-gray); text-transform: uppercase; letter-spacing: 0.08em;
}
.erp-tile-title { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: -0.3px; }
.erp-tile-desc { margin: 0 0 8px; color: var(--color-midtone-gray); font-size: 13px; }
.erp-tile-list { margin: 0; padding-left: 18px; color: var(--color-rich-black); font-size: 13px; }
.erp-tile-list li { margin: 4px 0; }
/* ── 개인경비 페이지 ── */
.erp-expense { display: flex; flex-direction: column; gap: var(--sp-20); }
.erp-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--sp-12);
}
.erp-summary-card {
border: 1px solid var(--color-subtle-ash);
border-radius: var(--r-card);
padding: var(--sp-16);
display: flex; flex-direction: column; gap: 6px;
background: var(--color-canvas-white);
}
.erp-summary-card--mini { padding: var(--sp-12) var(--sp-16); }
.erp-summary-label { font-size: 12px; color: var(--color-midtone-gray); }
.erp-summary-value { font-size: 20px; font-weight: 600; letter-spacing: -0.3px; font-variant-numeric: tabular-nums; }
.erp-summary-value--sm { font-size: 16px; }
.erp-card-block {
border: 1px solid var(--color-subtle-ash);
border-radius: var(--r-card);
background: var(--color-canvas-white);
overflow: hidden;
}
.erp-card-block-head {
display: flex; align-items: baseline; justify-content: space-between;
padding: var(--sp-16) var(--sp-20);
border-bottom: 1px solid var(--color-subtle-ash);
}
.erp-card-block-head h2 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: -0.2px; }
.erp-muted { color: var(--color-midtone-gray); font-size: 12px; }
.erp-form-grid {
display: grid; grid-template-columns: repeat(4, minmax(0,1fr));
gap: var(--sp-12); padding: var(--sp-16) var(--sp-20);
}
.erp-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--color-midtone-gray); }
.erp-field-wide { grid-column: span 2; }
.erp-field input, .erp-field select {
border: 1px solid var(--color-subtle-ash);
border-radius: var(--r-input);
padding: 6px 10px;
font-family: inherit; font-size: 14px; color: var(--color-rich-black);
background: var(--color-canvas-white);
outline: none;
}
.erp-field input:focus, .erp-field select:focus {
border-color: var(--color-rich-black);
box-shadow: 0 0 0 3px rgba(0,0,0,0.06);
}
.erp-form-actions {
grid-column: 1 / -1;
display: flex; justify-content: flex-end; gap: var(--sp-8);
padding-top: 4px;
}
.erp-btn-sm { padding: 4px 10px !important; font-size: 12px !important; }
.erp-table-wrap { overflow-x: auto; }
.erp-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.erp-table thead th {
text-align: left;
font-weight: 500; color: var(--color-midtone-gray);
padding: 10px var(--sp-16);
border-bottom: 1px solid var(--color-subtle-ash);
background: var(--color-canvas-white);
}
.erp-table tbody td {
padding: 10px var(--sp-16);
border-bottom: 1px solid var(--color-subtle-ash);
vertical-align: middle;
}
.erp-table tbody tr:last-child td { border-bottom: 0; }
.erp-table tbody tr:hover { background: var(--color-ghost-gray); }
.erp-row-sub { font-size: 12px; color: var(--color-midtone-gray); margin-top: 2px; }
.erp-empty { text-align: center; color: var(--color-midtone-gray); padding: var(--sp-24); }
/* ── 반응형 ── */
@media (max-width: 900px) {
.erp-app { grid-template-columns: 0 1fr; }
.erp-sidebar {
position: fixed; left: 0; top: 0; height: 100vh;
width: var(--erp-sidebar-w);
transform: translateX(-100%);
transition: transform .2s ease;
box-shadow: 0 0 0 1px var(--color-subtle-ash);
}
.erp-sidebar.is-open { transform: translateX(0); }
.erp-sidebar-mobile-toggle { display: inline-flex; }
.erp-form-grid { grid-template-columns: 1fr 1fr; }
.erp-field-wide { grid-column: span 2; }
}
@media (max-width: 560px) {
.erp-form-grid { grid-template-columns: 1fr; }
.erp-field-wide { grid-column: auto; }
.erp-page { padding: var(--sp-16); }
}
+8
View File
@@ -197,6 +197,14 @@ body.erp-body {
background: var(--color-rich-black); background: var(--color-rich-black);
} }
/* 검정 배경 요소는 글자 무조건 흰색.
<a> 버튼/배지의 a:link/a:visited(특이도 0,1,1) 클래스 색을 덮어쓰는 문제 차단. */
a.erp-btn-primary, a.erp-btn-primary:link, a.erp-btn-primary:visited, a.erp-btn-primary:hover,
.erp-badge-inverse, a.erp-badge-inverse:link, a.erp-badge-inverse:visited,
.erp-sidebar-item.is-active, a.erp-sidebar-item.is-active:link, a.erp-sidebar-item.is-active:visited {
color: var(--color-canvas-white) !important;
}
.erp-btn-ghost { .erp-btn-ghost {
background: transparent; background: transparent;
color: var(--color-rich-black); color: var(--color-rich-black);
+219
View File
@@ -0,0 +1,219 @@
/* 휴가 관리(vacation) 모듈 스타일.
DESIGN.md 토큰 준수 모노크롬, 10/14px radius, 카드 padding 16px.
캐시 버전: ?v=20260530a (변경 템플릿 head_extra ?v= 함께 올린다) */
:root {
--vac-ash: #e5e5e5;
--vac-ghost: #f2f2f2;
--vac-muted: #737373;
--vac-black: #0a0a0a;
--vac-red: #c22b10;
--vac-blue: #1d4ed8;
--vac-green: #10733a;
}
.vac { display: flex; flex-direction: column; gap: 16px; }
/* 검정 배경 버튼 글자 흰색 강제 */
.vac .erp-btn-primary { color: #fff !important; }
/* ── 페이지 액션 + 잔여 요약 ── */
.vac-actions {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap; flex-shrink: 0;
}
.vac-actions-main { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.vac-mini { margin-left: 4px; }
.vac-balance {
display: flex; align-items: baseline; gap: 8px;
font-size: 14px; color: var(--vac-black);
}
.vac-balance strong { font-weight: 600; }
.vac-bal-sep { color: var(--vac-ash); }
.vac-bal-remain strong { color: var(--vac-green); }
.vac-bal-year { margin-left: 6px; font-size: 12px; }
/* ── 레이아웃: 달력 좌 / 리스트 우 ── */
.vac-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 16px;
align-items: stretch;
flex: 1 1 auto; min-height: 0; /* 남은 높이 채움 → 문서 스크롤 방지 */
}
@media (max-width: 1100px) {
.vac-layout { grid-template-columns: 1fr; }
}
/* ── 달력 카드 ── */
.vac-cal-card {
padding: 16px;
display: flex; flex-direction: column; min-height: 0; overflow: hidden;
}
.vac-cal-head {
display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
flex-shrink: 0;
}
.vac-cal-title { font-size: 18pt; font-weight: 600; margin: 0; letter-spacing: -0.45px; }
.vac-nav-btn { padding: 4px 12px; font-size: 18pt; line-height: 1; }
.vac-today-btn { margin-left: auto; }
.vac-cal {
border: 1px solid var(--vac-ash); border-radius: 10px; overflow: hidden;
flex: 1 1 auto; min-height: 0;
display: flex; flex-direction: column;
}
.vac-wd-row {
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
background: var(--vac-ghost); border-bottom: 1px solid var(--vac-ash);
flex-shrink: 0;
}
.vac-wd {
text-align: center; padding: 8px 0; font-size: 15px; font-weight: 600;
color: var(--vac-black);
border-right: 1px solid var(--vac-ash);
}
.vac-wd:last-child { border-right: none; }
/* 주(week) — 날짜 셀 위에 bar 레이어를 오버레이. 6주가 높이를 균등 분할(유동). */
.vac-week {
position: relative; border-bottom: 1px solid var(--vac-ash);
flex: 1 1 0; min-height: 88px;
display: flex; flex-direction: column;
}
.vac-week:last-child { border-bottom: none; }
.vac-week-days {
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
flex: 1 1 auto; min-height: 0;
}
.vac-day {
height: 100%;
border-right: 1px solid var(--vac-ash);
padding: 6px 8px; text-decoration: none;
display: block; box-sizing: border-box;
}
.vac-day:last-child { border-right: none; }
.vac-day-num { font-size: 20px; font-weight: 600; color: var(--vac-black); }
.vac-out { background: #fafafa; }
.vac-out .vac-day-num { color: #bbb; }
.vac-today { background: #f5f5f5; }
.vac-today .vac-day-num {
background: var(--vac-black);
color: #fff !important; /* vac-red/vac-blue 글자색(!important) 이김 */
border-radius: 9999px; padding: 2px 9px;
}
/* 오늘이 일/공휴일=빨강배경, 토요일=파랑배경, 평일=검정배경 (모두 흰글씨) */
.vac-today .vac-day-num.vac-red { background: var(--vac-red); }
.vac-today .vac-day-num.vac-blue { background: var(--vac-blue); }
.vac-selected { box-shadow: inset 0 0 0 2px var(--vac-black); }
.vac-red { color: var(--vac-red) !important; }
.vac-blue { color: var(--vac-blue) !important; }
/* bar 오버레이 — 날짜 숫자 아래(top)부터 7열 그리드로 겹쳐 그림 */
.vac-week-bars {
position: absolute; left: 0; right: 0; top: 38px; bottom: 4px;
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: 22px; row-gap: 3px;
pointer-events: none;
}
/* ── 휴가 bar (구글 달력 스타일) ── */
.vac-bar {
margin: 0 3px; padding: 0 8px; height: 20px; line-height: 20px;
border-radius: 6px; font-size: 13px; text-decoration: none;
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
align-self: center; pointer-events: auto;
}
.vac-bar-label { pointer-events: none; }
.vac-bar-l { border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: 0; }
.vac-bar-r { border-top-right-radius: 0; border-bottom-right-radius: 0; margin-right: 0; }
.vac-bar-draft { background: #fff; border: 1px dashed var(--vac-muted); color: var(--vac-muted); }
.vac-bar-submit { background: var(--vac-ghost); border: 1px solid var(--vac-muted); color: var(--vac-black); }
.vac-bar-approve, a.vac-bar-approve:link, a.vac-bar-approve:visited { background: var(--vac-black); color: #fff; }
.vac-bar-reject { background: #fff; border: 1px solid var(--vac-red); color: var(--vac-red); text-decoration: line-through; }
.vac-bar-cancel { background: #fff; border: 1px dashed var(--vac-ash); color: #bbb; opacity: 0.7; }
/* 범례 */
.vac-legend { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; flex-shrink: 0; }
.vac-leg { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--vac-muted); }
.vac-dot { width: 14px; height: 12px; border-radius: 4px; display: inline-block; }
/* ── 선택일 리스트 ── */
.vac-list-card {
padding: 16px; min-height: 0; overflow-y: auto; /* 긴 목록은 패널 내부 스크롤 */
}
.vac-list-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; }
.vac-list-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
.vac-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.vac-list-link {
display: block; text-decoration: none; color: var(--vac-black);
border: 1px solid var(--vac-ash); border-radius: 10px; padding: 10px 12px;
}
.vac-list-link:hover { background: var(--vac-ghost); }
.vac-list-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.vac-list-meta { font-size: 12px; margin-top: 4px; }
.vac-list-reason { font-size: 12px; margin-top: 4px; }
.vac-empty { padding: 12px 0; }
/* ── 신청 폼 ── */
.vac-form-card { padding: 16px; max-width: 760px; }
.vac-form-grid {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; margin-bottom: 12px;
}
.vac-full { grid-column: 1 / -1; }
.vac-days-preview { margin: 12px 0; font-size: 14px; }
.vac-days-preview strong { font-size: 16px; font-weight: 600; }
.vac-form-actions { display: flex; gap: 8px; margin-top: 8px; }
/* ── 상세 ── */
.vac-detail-card { padding: 16px; max-width: 760px; }
.vac-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.vac-detail-head h2 { font-size: 18px; font-weight: 600; margin: 0; }
.vac-detail-grid {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 24px; margin: 16px 0;
}
.vac-detail-grid dt { font-size: 12px; color: var(--vac-muted); margin-bottom: 2px; }
.vac-detail-grid dd { margin: 0; font-size: 14px; }
.vac-detail-block { margin: 12px 0; }
.vac-detail-label { font-size: 12px; color: var(--vac-muted); margin-bottom: 4px; }
.vac-reject-block p { color: var(--vac-red); }
.vac-approve-box {
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
background: var(--vac-ghost); border-radius: 10px; padding: 12px; margin: 12px 0;
}
.vac-reject-form { display: flex; gap: 6px; align-items: center; }
.vac-inline-form { display: inline; }
.vac-detail-actions { display: flex; gap: 8px; margin-top: 16px; align-items: center; }
.vac-push-right { margin-left: auto; }
/* ── 승인 대기 ── */
.vac-pending-table .vac-act-col { width: 280px; }
.vac-pending-acts { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.vac-reason-cell { max-width: 260px; }
.erp-btn-sm { padding: 2px 10px; font-size: 12px; }
.erp-input-sm { padding: 2px 8px; font-size: 12px; min-width: 140px; }
/* ── 설정 ── */
.vac-settings-head { margin-bottom: 4px; }
.vac-year-form { display: flex; gap: 8px; align-items: flex-end; }
.vac-year-field { max-width: 140px; }
.vac-settings-grid {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px;
align-items: start;
}
@media (max-width: 1100px) { .vac-settings-grid { grid-template-columns: 1fr; } }
.vac-set-card { padding: 16px; }
.vac-card-head h2 { font-size: 16px; font-weight: 600; margin: 0 0 12px; }
.vac-holiday-form, .vac-balance-form {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px; margin-bottom: 12px; align-items: end;
}
.vac-holiday-form button, .vac-balance-form button { grid-column: 1 / -1; }
.vac-check { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.vac-set-scroll { max-height: 420px; overflow: auto; }
.vac-set-note { font-size: 12px; margin-top: 8px; }
+86
View File
@@ -0,0 +1,86 @@
/* + .
서버(store.compute_days) 공휴일 포함 정확히 재계산한다. 여기선 주말만 제외한 근사치. */
(function () {
"use strict";
var form = document.getElementById("vac-form");
if (!form) return;
var type = document.getElementById("vac-type");
var start = document.getElementById("vac-start");
var startHalf = document.getElementById("vac-start-half");
var endField = document.getElementById("vac-end-field");
var end = document.getElementById("vac-end");
var endHalfField = document.getElementById("vac-end-half-field");
var endHalf = document.getElementById("vac-end-half");
var startHalfField = document.getElementById("vac-start-half-field");
var out = document.getElementById("vac-days-out");
function isHalfType(t) { return t === "오전반차" || t === "오후반차"; }
function parseDate(s) {
if (!s) return null;
var p = s.split("-");
if (p.length !== 3) return null;
return new Date(+p[0], +p[1] - 1, +p[2]);
}
function workingDays(sd, ed) {
var n = 0;
var d = new Date(sd.getTime());
while (d <= ed) {
var wd = d.getDay(); // 0=일, 6=토
if (wd !== 0 && wd !== 6) n++;
d.setDate(d.getDate() + 1);
}
return n;
}
function isWorking(d) {
var wd = d.getDay();
return wd !== 0 && wd !== 6;
}
function recalc() {
var t = type.value;
var half = isHalfType(t);
// 반차 종류면 종료일/구분 숨김, 단일 0.5
endField.style.display = half ? "none" : "";
endHalfField.style.display = half ? "none" : "";
startHalfField.style.display = half ? "none" : "";
if (half) {
out.textContent = "0.5";
return;
}
var sd = parseDate(start.value);
var ed = parseDate(end.value) || sd;
if (!sd || !ed || ed < sd) { out.textContent = "—"; return; }
if (sd.getTime() === ed.getTime()) {
out.textContent = (startHalf.value === "am" || startHalf.value === "pm") ? "0.5" : "1";
return;
}
var n = workingDays(sd, ed);
var total = n;
if ((startHalf.value === "am" || startHalf.value === "pm") && isWorking(sd)) total -= 0.5;
if ((endHalf.value === "am" || endHalf.value === "pm") && isWorking(ed)) total -= 0.5;
out.textContent = total > 0 ? String(total) : "0";
}
// 시작일 변경 시 종료일이 비었거나 더 빠르면 맞춰줌
start.addEventListener("change", function () {
if (!end.value || parseDate(end.value) < parseDate(start.value)) {
end.value = start.value;
}
recalc();
});
[type, end, startHalf, endHalf].forEach(function (el) {
if (el) el.addEventListener("change", recalc);
});
recalc();
})();
+69 -5
View File
@@ -10,19 +10,34 @@ import json
import os import os
import tempfile import tempfile
import threading import threading
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
from .timezone import now_kst_iso
# 슈퍼 관리자 — 강등/삭제 불가 # 슈퍼 관리자 — 강등/삭제 불가
SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr" SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr"
# 시스템에서 지원하는 모듈 키 — 신규 추가시 여기 + 메뉴/템플릿 동시 갱신 # 시스템에서 지원하는 권한 키.
MODULE_KEYS: tuple[str, ...] = ("corm", "order", "expense", "vacation") # - 접근권한: 모듈 페이지 진입 허용
# - 승인자: 결재 워크플로에서 승인/반려 가능
# 신규 추가 시 여기 + admin.html MODULE_LABELS + 라우터 검사 동시 갱신.
MODULE_KEYS: tuple[str, ...] = (
"corm",
"order",
"expense",
"vacation",
"cupang",
"expense_approver",
"vacation_approver",
)
# 승인자 권한 키 — 결재 워크플로에서 별도 검사할 때 사용
APPROVER_KEYS: tuple[str, ...] = ("expense_approver", "vacation_approver")
def _now_iso() -> str: def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds") return now_kst_iso()
class UserStore: class UserStore:
@@ -88,6 +103,42 @@ class UserStore:
data = self._read() data = self._read()
return [self._enrich(email, rec) for email, rec in data["users"].items()] return [self._enrich(email, rec) for email, rec in data["users"].items()]
def create_user(
self,
*,
email: str,
name: str = "",
role: str = "user",
modules: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""관리자가 이메일만으로 신규 사용자 등록. 로그인 전이라도 권한 부여 가능."""
email = email.lower().strip()
if not email or "@" not in email:
raise ValueError("올바른 이메일 형식이 아닙니다.")
if role not in ("admin", "user"):
raise ValueError(f"role 값이 잘못되었습니다: {role}")
is_super = email == SUPER_ADMIN_EMAIL
with self._lock:
data = self._read()
if email in data["users"]:
raise ValueError(f"이미 등록된 사용자입니다: {email}")
now = _now_iso()
final_role = "admin" if is_super else role
rec = {
"email": email,
"name": name or email.split("@")[0],
"picture": "",
"role": final_role,
"modules": self._normalize_modules(
modules, is_admin=(final_role == "admin")
),
"created_at": now,
"last_login": "",
}
data["users"][email] = rec
self._write_atomic(data)
return self._enrich(email, rec)
def upsert_login(self, *, email: str, name: str, picture: str) -> dict[str, Any]: def upsert_login(self, *, email: str, name: str, picture: str) -> dict[str, Any]:
"""로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신.""" """로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신."""
email = email.lower().strip() email = email.lower().strip()
@@ -179,7 +230,20 @@ def has_module(user_rec: dict[str, Any] | None, module: str) -> bool:
if is_admin(user_rec): if is_admin(user_rec):
return True return True
mods = user_rec.get("modules") or {} mods = user_rec.get("modules") or {}
return bool(mods.get(module)) if mods.get(module):
return True
# 승인자(<module>_approver) 권한이 있으면 해당 모듈 접근도 허용 —
# 본인 경비 등록/제출도 가능해야 하므로.
if module in APPROVER_KEYS:
return False
if mods.get(f"{module}_approver"):
return True
return False
def is_approver(user_rec: dict[str, Any] | None, kind: str) -> bool:
"""결재 승인자 권한 검사. kind 예: 'expense', 'vacation'."""
return has_module(user_rec, f"{kind}_approver")
def allowed_modules(user_rec: dict[str, Any] | None) -> set[str]: def allowed_modules(user_rec: dict[str, Any] | None) -> set[str]:
+110 -40
View File
@@ -5,21 +5,44 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>사용자/권한 관리 — DBX ERP</title> <title>사용자/권한 관리 — DBX ERP</title>
<link rel="stylesheet" href="/static/erp.css" /> <link rel="stylesheet" href="/static/erp.css" />
<style>
.erp-admin-toolbar {
display: flex; align-items: center; gap: var(--sp-12);
margin: var(--sp-16) 0;
flex-wrap: wrap;
}
.erp-admin-toolbar .erp-input { flex: 0 1 280px; }
.erp-add-form {
display: flex; gap: var(--sp-8); align-items: center;
margin-left: auto; flex-wrap: wrap;
}
.erp-add-form input, .erp-add-form select {
border: 1px solid var(--color-subtle-ash);
border-radius: var(--r-input);
padding: 6px 10px; font-size: 13px;
font-family: inherit;
}
.erp-mod-th { text-align: center; font-size: 11px; padding: 6px 4px; }
.erp-mod-cell { text-align: center; padding: 4px; }
.erp-mod-group { display: inline-block; padding: 2px 6px; border-radius: 6px;
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
font-size: 10px; font-weight: 500; }
</style>
</head> </head>
<body class="erp-body"> <body class="erp-body">
<div class="erp-shell"> <div class="erp-shell">
<!-- ── 상단 네비게이션 ── -->
<nav class="erp-nav"> <nav class="erp-nav">
<div class="erp-nav-left"> <div class="erp-nav-left">
<a href="/" class="erp-brand"> <a href="/" class="erp-brand">
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" /> <img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
<span class="erp-brand-divider"></span> <span class="erp-brand-divider"></span>
<span class="erp-brand-system">ERP 시스템</span> <span class="erp-brand-system">DBX ERP System</span>
</a> </a>
</div> </div>
<div class="erp-nav-right"> <div class="erp-nav-right">
<a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a> <a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a>
<div class="erp-user-chip"> <div class="erp-user-chip">
@@ -37,39 +60,45 @@
</div> </div>
</nav> </nav>
<main class="erp-main" style="max-width: 1100px;"> <main class="erp-main" style="max-width: 1280px;">
<h1 class="erp-greeting">사용자 / 권한 관리</h1> <h1 class="erp-greeting">사용자 / 권한 관리</h1>
<p class="erp-greeting-sub"> <p class="erp-greeting-sub">
회사 계정으로 로그인한 임직원의 모듈 접근 권한을 관리합니다. 이메일만으로 사용자 등록 가능 (회사 도메인 자동 검사).
토글을 변경한 뒤 <strong>저장</strong> 버튼을 누르세요. 토글을 변경한 뒤 <strong>저장</strong>을 누르세요.
</p> </p>
<!-- 이메일 등록 폼 + 검색 -->
<div class="erp-admin-toolbar"> <div class="erp-admin-toolbar">
<input type="search" <input type="search" id="user-search" class="erp-input"
id="user-search" placeholder="이메일/이름 검색…" style="padding: 6px 10px;" />
class="erp-input"
placeholder="이메일 또는 이름으로 검색…" />
<span class="erp-section-meta" id="user-count">총 {{ users | length }}명</span> <span class="erp-section-meta" id="user-count">총 {{ users | length }}명</span>
<form id="add-user-form" class="erp-add-form">
<input type="email" name="email" required placeholder="email@dbxcorp.co.kr" style="width: 220px;" />
<input type="text" name="name" placeholder="이름(선택)" style="width: 120px;" />
<select name="role">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
<button type="submit" class="erp-btn erp-btn-primary">사용자 추가</button>
</form>
</div> </div>
<div class="erp-table-wrap"> <div class="erp-table-wrap">
<table class="erp-table" id="users-table"> <table class="erp-table" id="users-table">
<thead> <thead>
<tr> <tr>
<th style="width: 28%;">사용자</th> <th style="width: 22%;">사용자</th>
<th style="width: 11%;">역할</th> <th style="width: 80px;">역할</th>
{% for key in module_keys %} {% for key in module_keys %}
<th style="text-align:center; width: 9%;"> <th class="erp-mod-th">
{% if key == 'corm' %}CORM {{ module_labels.get(key, key) }}
{% elif key == 'order' %}Order {% if key in approver_keys %}<br><span class="erp-mod-group">승인</span>{% endif %}
{% elif key == 'expense' %}개인경비
{% elif key == 'vacation' %}휴가
{% else %}{{ key }}{% endif %}
</th> </th>
{% endfor %} {% endfor %}
<th style="width: 13%;">최근 로그인</th> <th style="width: 120px;">최근 로그인</th>
<th style="width: 12%; text-align: right;">동작</th> <th style="width: 130px; text-align: right;">동작</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -102,7 +131,7 @@
</select> </select>
</td> </td>
{% for key in module_keys %} {% for key in module_keys %}
<td style="text-align:center;"> <td class="erp-mod-cell">
<label class="erp-switch"> <label class="erp-switch">
<input type="checkbox" <input type="checkbox"
data-field="module" data-field="module"
@@ -114,11 +143,15 @@
</td> </td>
{% endfor %} {% endfor %}
<td style="font-size:12px; color: var(--color-midtone-gray);"> <td style="font-size:12px; color: var(--color-midtone-gray);">
{{ (u.last_login or '')[:16].replace('T', ' ') }} {{ (u.last_login or '미접속')[:16].replace('T', ' ') }}
</td> </td>
<td style="text-align: right;"> <td style="text-align: right;">
<button class="erp-btn erp-btn-primary" data-action="save" <button class="erp-btn erp-btn-primary" data-action="save"
{% if u.is_super_admin %}disabled{% endif %}>저장</button> {% if u.is_super_admin %}disabled{% endif %}>저장</button>
{% if not u.is_super_admin %}
<button class="erp-btn erp-btn-outline" data-action="delete"
style="padding: 4px 8px; font-size: 12px;">삭제</button>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -127,8 +160,9 @@
</div> </div>
<p class="erp-section-meta" style="margin-top: var(--sp-16);"> <p class="erp-section-meta" style="margin-top: var(--sp-16);">
<strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자이므로 권한 변경할 수 없습니다. <strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자, 권한 변경/삭제 불가.
관리자(admin) 역할로 지정된 사용자에게는 모든 모듈 권한 자동 부여됩니다. admin 역할 모든 모듈 권한 자동 부여.
<code>expense_approver</code> / <code>vacation_approver</code> 는 결재 승인 권한.
</p> </p>
</main> </main>
@@ -141,6 +175,7 @@
const searchInput = document.getElementById("user-search"); const searchInput = document.getElementById("user-search");
const userCountEl = document.getElementById("user-count"); const userCountEl = document.getElementById("user-count");
const toastEl = document.getElementById("toast"); const toastEl = document.getElementById("toast");
const addForm = document.getElementById("add-user-form");
function markDirty(tr) { tr.setAttribute("data-dirty", "true"); } function markDirty(tr) { tr.setAttribute("data-dirty", "true"); }
function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); } function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); }
@@ -150,20 +185,16 @@
toastEl.dataset.type = type || "info"; toastEl.dataset.type = type || "info";
toastEl.dataset.visible = "true"; toastEl.dataset.visible = "true";
clearTimeout(showToast._t); clearTimeout(showToast._t);
showToast._t = setTimeout(() => { showToast._t = setTimeout(() => { toastEl.dataset.visible = "false"; }, 2400);
toastEl.dataset.visible = "false";
}, 2400);
} }
// 역할 변경시 모듈 토글 활성/비활성 갱신
function syncModuleSwitches(tr) { function syncModuleSwitches(tr) {
const isSuper = tr.dataset.super === "true"; const isSuper = tr.dataset.super === "true";
const role = tr.querySelector("select[data-field='role']").value; const role = tr.querySelector("select[data-field='role']").value;
const adminRole = role === "admin"; const adminRole = role === "admin";
tr.querySelectorAll("input[data-field='module']").forEach((cb) => { tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
if (isSuper || adminRole) { if (isSuper || adminRole) {
cb.checked = true; cb.checked = true; cb.disabled = true;
cb.disabled = true;
} else { } else {
cb.disabled = false; cb.disabled = false;
} }
@@ -173,25 +204,23 @@
tbody.addEventListener("change", (e) => { tbody.addEventListener("change", (e) => {
const tr = e.target.closest("tr"); const tr = e.target.closest("tr");
if (!tr) return; if (!tr) return;
if (e.target.dataset.field === "role") { if (e.target.dataset.field === "role") syncModuleSwitches(tr);
syncModuleSwitches(tr);
}
markDirty(tr); markDirty(tr);
}); });
tbody.addEventListener("click", async (e) => { tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button[data-action='save']"); const btn = e.target.closest("button");
if (!btn) return; if (!btn) return;
const tr = btn.closest("tr"); const tr = btn.closest("tr");
const email = tr.dataset.email; const email = tr.dataset.email;
if (btn.dataset.action === "save") {
const role = tr.querySelector("select[data-field='role']").value; const role = tr.querySelector("select[data-field='role']").value;
const modules = {}; const modules = {};
tr.querySelectorAll("input[data-field='module']").forEach((cb) => { tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
modules[cb.dataset.module] = cb.checked; modules[cb.dataset.module] = cb.checked;
}); });
btn.disabled = true; btn.textContent = "저장 중…";
btn.disabled = true;
btn.textContent = "저장 중…";
try { try {
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, { const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
method: "PUT", method: "PUT",
@@ -208,12 +237,53 @@
} catch (err) { } catch (err) {
showToast(`저장 실패: ${err.message}`, "error"); showToast(`저장 실패: ${err.message}`, "error");
} finally { } finally {
btn.disabled = false; btn.disabled = false; btn.textContent = "저장";
btn.textContent = "저장"; }
} else if (btn.dataset.action === "delete") {
if (!confirm(`${email} 사용자를 삭제할까요? 복구 불가.`)) return;
try {
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
method: "DELETE", credentials: "same-origin",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || `HTTP ${res.status}`);
}
tr.remove();
showToast(`${email} 삭제 완료`, "info");
} catch (err) {
showToast(`삭제 실패: ${err.message}`, "error");
}
}
});
// 사용자 추가
addForm.addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(addForm);
const payload = {
email: fd.get("email").trim().toLowerCase(),
name: fd.get("name").trim(),
role: fd.get("role"),
};
try {
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || `HTTP ${res.status}`);
}
showToast(`${payload.email} 등록 완료. 새로고침합니다.`, "info");
setTimeout(() => location.reload(), 800);
} catch (err) {
showToast(`등록 실패: ${err.message}`, "error");
} }
}); });
// 검색
searchInput.addEventListener("input", () => { searchInput.addEventListener("input", () => {
const q = searchInput.value.toLowerCase().trim(); const q = searchInput.value.toLowerCase().trim();
let visible = 0; let visible = 0;
+166
View File
@@ -0,0 +1,166 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ page_title or "ERP" }} — DBX Corporation</title>
<link rel="stylesheet" href="/static/erp.css?v=20260530q" />
<link rel="stylesheet" href="/static/erp-shell.css?v=20260530q" />
<link rel="stylesheet" href="/static/erp-attach-viewer.css" />
{% block head_extra %}{% endblock %}
</head>
<body class="erp-body erp-app-body">
<div class="erp-app">
<!-- ── 좌측 사이드바 ── -->
<aside class="erp-sidebar" data-collapsed="false">
<div class="erp-sidebar-head">
<a href="/" class="erp-sidebar-brand">
<img src="/static/dbx-logo.png" alt="DBX" class="erp-sidebar-logo" />
<span class="erp-sidebar-system">DBX ERP System</span>
</a>
<button type="button" class="erp-sidebar-toggle" id="erp-sidebar-toggle" aria-label="사이드바 접기">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
</div>
<nav class="erp-sidebar-nav">
{% set last_group = namespace(value=None) %}
{% for item in nav_items %}
{% if item.group != last_group.value %}
<div class="erp-sidebar-group">{{ item.group }}</div>
{% set last_group.value = item.group %}
{% endif %}
{% set classes = "erp-sidebar-item" %}
{% if item.active %}{% set classes = classes + " is-active" %}{% endif %}
{% if item.disabled %}{% set classes = classes + " is-disabled" %}{% endif %}
{% if item.disabled %}
<div class="{{ classes }}" title="{{ item.disabled_reason or '준비중' }}">
{% else %}
<a class="{{ classes }}" href="{{ item.url }}"
{% if item.target == '_blank' %}target="_blank" rel="noopener noreferrer"{% endif %}>
{% endif %}
<span class="erp-sidebar-icon">{{ item.icon | safe }}</span>
<span class="erp-sidebar-label">{{ item.label }}</span>
{% if item.badge %}<span class="erp-sidebar-badge">{{ item.badge }}</span>{% endif %}
{% if item.target == '_blank' %}
<span class="erp-sidebar-ext" aria-hidden="true">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</span>
{% endif %}
{% if item.disabled %}
</div>
{% else %}
</a>
{% endif %}
{% endfor %}
</nav>
<div class="erp-sidebar-foot">
{% if user.is_super_admin %}
<a class="erp-sidebar-item" href="/modules" title="업무 모듈 선택 화면">
<span class="erp-sidebar-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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"/>
</svg>
</span>
<span class="erp-sidebar-label">업무 모듈</span>
</a>
{% endif %}
{% if is_admin %}
<a class="erp-sidebar-item" href="/admin">
<span class="erp-sidebar-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2 4 6v6c0 5 3.4 9.4 8 10 4.6-.6 8-5 8-10V6l-8-4Z"/>
</svg>
</span>
<span class="erp-sidebar-label">관리자</span>
</a>
{% endif %}
</div>
</aside>
<!-- ── 우측 콘텐츠 ── -->
<div class="erp-content">
<header class="erp-topbar">
<div class="erp-topbar-left">
<button type="button" class="erp-sidebar-mobile-toggle" id="erp-sidebar-mobile-toggle" aria-label="메뉴 열기">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<div class="erp-topbar-title">
<h1>{{ page_title or "" }}</h1>
{% if page_subtitle %}<p>{{ page_subtitle }}</p>{% endif %}
</div>
</div>
<div class="erp-topbar-right">
<div class="erp-user-chip">
{% if user.picture %}
<img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
{% else %}
<span class="erp-user-avatar-fallback">{{ user.name[0] | upper }}</span>
{% endif %}
<div class="erp-user-info">
<span class="erp-user-name">{{ user.name }}</span>
<span class="erp-user-email">{{ user.email }}</span>
</div>
</div>
<a class="erp-btn erp-btn-outline" href="/logout">로그아웃</a>
</div>
</header>
<main class="erp-page">
{% block content %}{% endblock %}
</main>
</div>
</div>
<script>
(function () {
const sidebar = document.querySelector(".erp-sidebar");
const collapseBtn = document.getElementById("erp-sidebar-toggle");
const mobileBtn = document.getElementById("erp-sidebar-mobile-toggle");
if (collapseBtn) {
collapseBtn.addEventListener("click", () => {
const next = sidebar.dataset.collapsed === "true" ? "false" : "true";
sidebar.dataset.collapsed = next;
try { localStorage.setItem("erp_sidebar_collapsed", next); } catch (_) {}
});
try {
const saved = localStorage.getItem("erp_sidebar_collapsed");
if (saved === "true") sidebar.dataset.collapsed = "true";
} catch (_) {}
}
if (mobileBtn) {
mobileBtn.addEventListener("click", () => {
sidebar.classList.toggle("is-open");
});
}
})();
</script>
<script src="/static/erp-attach-viewer.js" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="erp-home">
<div class="erp-hero">
<h2 class="erp-hero-title">안녕하세요, {{ user.name }}님</h2>
<p class="erp-hero-sub" id="erp-hero-sub"></p>
</div>
<div class="erp-home-grid">
<article class="erp-tile">
<div class="erp-tile-head">
<span class="erp-tile-label">바로가기</span>
<span class="erp-badge erp-badge-neutral">개인업무</span>
</div>
<h3 class="erp-tile-title">개인경비</h3>
<p class="erp-tile-desc">법인카드/개인지출 등록과 정산 신청.</p>
<a class="erp-btn erp-btn-primary" href="/expense/">개인경비 열기</a>
</article>
<article class="erp-tile">
<div class="erp-tile-head">
<span class="erp-tile-label">바로가기</span>
<span class="erp-badge erp-badge-outline">준비중</span>
</div>
<h3 class="erp-tile-title">휴가</h3>
<p class="erp-tile-desc">연차/반차 신청과 잔여일수 확인.</p>
<button class="erp-btn erp-btn-outline" disabled>오픈 예정</button>
</article>
<article class="erp-tile">
<div class="erp-tile-head">
<span class="erp-tile-label">알림</span>
<span class="erp-badge erp-badge-outline">공지</span>
</div>
<h3 class="erp-tile-title">시스템 안내</h3>
<ul class="erp-tile-list">
<li>좌측 메뉴에서 사용 가능한 업무 모듈을 확인하세요.</li>
<li>외부 모듈(주문/CORM)은 새 창에서 열립니다.</li>
<li>권한이 필요한 모듈은 관리자에게 요청하세요.</li>
</ul>
</article>
</div>
</section>
<script>
(function() {
const d = new Date();
const h = d.getHours();
let greet = "오늘도 좋은 하루 되세요.";
if (h < 5) greet = "늦은 시간까지 수고 많으세요.";
else if (h < 12) greet = "활기찬 아침입니다.";
else if (h < 18) greet = "오후 업무 화이팅입니다.";
else greet = "오늘도 고생 많으셨습니다.";
const days = ["일","월","화","수","목","금","토"];
const dateStr = `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,"0")}.${String(d.getDate()).padStart(2,"0")} (${days[d.getDay()]})`;
const el = document.getElementById("erp-hero-sub");
if (el) el.textContent = `${dateStr} · ${greet}`;
})();
</script>
{% endblock %}
+1 -1
View File
@@ -16,7 +16,7 @@
<a href="/" class="erp-brand"> <a href="/" class="erp-brand">
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" /> <img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
<span class="erp-brand-divider"></span> <span class="erp-brand-divider"></span>
<span class="erp-brand-system">ERP 시스템</span> <span class="erp-brand-system">DBX ERP System</span>
</a> </a>
</div> </div>
+38
View File
@@ -0,0 +1,38 @@
"""프로젝트 공통 시간대 — 모든 시간은 한국 시간(KST, UTC+9)으로 표시/계산한다.
대한민국은 서머타임(DST) 없으므로 고정 오프셋 +9 충분하다.
zoneinfo/tzdata 의존 없이 어디서나 동일하게 동작한다.
- DB TIMESTAMPTZ UTC 저장되고 psycopg aware datetime(UTC) 돌려준다.
표시 직전에 `to_kst_iso()` KST 문자열로 변환한다.
- "오늘"/"지금" 판정은 `today_kst()` / `now_kst()` 쓴다(서버 로컬 TZ 무관).
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
KST = timezone(timedelta(hours=9), name="KST")
def now_kst() -> datetime:
"""현재 시각(KST, tz-aware)."""
return datetime.now(KST)
def today_kst() -> date:
"""오늘 날짜(KST 기준)."""
return now_kst().date()
def now_kst_iso() -> str:
return now_kst().isoformat(timespec="seconds")
def to_kst_iso(dt: datetime | None, *, timespec: str = "seconds") -> str | None:
"""datetime → KST ISO 문자열. naive 는 UTC 로 간주 후 변환."""
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(KST).isoformat(timespec=timespec)
+11 -1
View File
@@ -10,10 +10,20 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
# 사용자/권한 JSON 저장소 — 볼륨에 영구 보관 # 사용자/권한 JSON 저장소 + 첨부 업로드 — 볼륨에 영구 보관
DATA_DIR: /data DATA_DIR: /data
volumes: volumes:
- dbx-main-data:/data - dbx-main-data:/data
networks:
- default
- postgres_default # postgres-db 컨테이너와 통신 (DSN host=postgres-db)
volumes: volumes:
dbx-main-data: dbx-main-data:
networks:
# main_default — compose 자동 생성, 동일 프로젝트 내부 통신용
default:
# postgres_default — postgres-db 가 속한 외부 네트워크 (별도 compose 가 만듦)
postgres_default:
external: true
+288
View File
@@ -0,0 +1,288 @@
# Databases
## PostgreSQL DB 목록
| DB명 | 용도 |
| --- | --- |
| `itemcode_db` | 상품코드, 단품/세트 구성, 채널 ↔ 사내 코드 매칭 |
| `orderlist_db` | 주문 수집·분석·관리 (구 `orderlist_app`) |
| `return_db` | 반품·교환·CS 데이터 |
| `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 |
| `cupang_db` | 쿠팡 밀크런 출고 묶음 / 출고 라인 / 입고센터 / 박스 입수량 규칙 |
---
## 명명 규칙
- 모든 DB 이름은 **소문자 + 언더스코어**, **`_db`로 끝낸다**.
- ✅ `inventory_db`, `cs_db`
- ❌ `inventoryApp`, `cs-database`
- 신규 DB가 필요하면 **사용자 승인 후** 생성한다.
- 신규 DB 생성 시 함께 정리할 항목:
- 용도와 책임 모듈
- 소유자(OWNER) 계정
- 백업 주기와 위치
- `.env`의 연결 정보 변수명
---
## 레거시 이름 매핑
| 예전 이름 | 현재 기준 |
| --- | --- |
| `orderlist_app` | `orderlist_db` |
코드/문서/설정에서 `orderlist_app`을 발견하면 `orderlist_db`로 수정한다 (수정 전 영향 범위 확인).
---
## DB 작업 원칙
1. **작업 전 백업 우선**. 백업 없는 변경은 진행하지 않는다.
2. 테이블 owner, 권한, sequence 권한을 확인한다.
3. 운영 DB의 `DROP`, `TRUNCATE`, 조건 없는 대량 `DELETE/UPDATE`**사용자 확인 없이 실행 금지**.
4. 스키마 변경은 마이그레이션 스크립트(`scripts/` 또는 alembic 등)로 관리한다.
5. 운영 DB와 개발 DB의 접속 정보를 혼동하지 않는다 (`.env`로 분리).
---
## 위험 명령 (사용자 승인 필수)
| 명령 | 비고 |
| --- | --- |
| `DROP DATABASE` | 복구 불가. 백업 없으면 절대 실행 금지 |
| `DROP TABLE` / `DROP SCHEMA` | 의존 객체 확인 필수 |
| `TRUNCATE` | FK CASCADE 시 광범위 삭제 위험 |
| 조건 없는 `DELETE` / `UPDATE` | `WHERE` 없는 문 차단 |
| `docker volume rm <postgres_volume>` | 운영 데이터 영구 손실 |
| `docker compose down -v` | 볼륨까지 제거. 운영에서 금지 |
실행 전 반드시:
1. 백업 확인 (`pg_dump`, 컨테이너 외부 마운트)
2. 영향 범위 설명
3. 사용자 명시 승인
---
## 자주 쓰는 점검 명령
```bash
# 컨테이너/네트워크
docker ps
docker network ls
docker inspect <postgres_container_name>
# DB 목록 / 접속
sudo -u postgres psql -l
docker exec -it <postgres_container_name> psql -U postgres -l
# 특정 DB 접속
docker exec -it <postgres_container_name> psql -U <user> -d itemcode_db
# 테이블/권한 확인
\dt
\dn+
\du
\z <table_name>
```
---
## expense_db 스키마 / 초기화
DDL: `scripts/sql/expense_db_init.sql` (멱등). DB·역할·테이블·인덱스·트리거를 한 번에 생성.
### 테이블 `expense_items`
| 컬럼 | 타입 | 비고 |
| --- | --- | --- |
| `id` | TEXT PK | 12자 hex (uuid4 앞 12자) |
| `owner` | TEXT | 소유자 email (소문자) |
| `spent_at` | DATE | 사용일 |
| `category` | TEXT | 식대/교통/숙박/비품/접대/통신/기타 |
| `method` | TEXT | 법인카드/개인지출/현금 |
| `merchant` | TEXT | 가맹점 |
| `amount` | BIGINT | 원 단위, ≥ 0 |
| `memo` | TEXT | 비고 |
| `status` | TEXT | 작성중/제출/승인/반려/정산완료 |
| `approver_email` | TEXT | 결재자 email (승인/반려/정산 시 기록) |
| `decided_at` | TIMESTAMPTZ | 결재 시점 |
| `reject_reason` | TEXT | 반려 사유 |
| `created_at` / `updated_at` | TIMESTAMPTZ | 트리거로 자동 갱신 |
인덱스: `(owner, spent_at DESC)`, `(status)`, `(created_at DESC)`.
### 테이블 `expense_attachments`
영수증/기타파일 메타데이터. 실제 파일은 `DATA_DIR/uploads/expense/{item_id}/` 에 저장.
| 컬럼 | 타입 | 비고 |
| --- | --- | --- |
| `id` | TEXT PK | 12자 hex |
| `item_id` | TEXT FK | `expense_items(id)` ON DELETE CASCADE |
| `owner` | TEXT | 업로드한 사용자 email |
| `kind` | TEXT | `receipt` 또는 `other` (CHECK) |
| `filename` | TEXT | 원본 파일명 |
| `stored_path` | TEXT | 디스크 경로 (절대) |
| `content_type` | TEXT | MIME |
| `size_bytes` | BIGINT | 바이트 |
| `uploaded_at` | TIMESTAMPTZ | 업로드 시각 |
인덱스: `(item_id)`.
### 결재 워크플로
```
작성중 ─submit──▶ 제출 ─approve──▶ 승인 ─settle──▶ 정산완료
▲ │
└─revert/reject──┴─reject──▶ 반려 ─revert──▶ 작성중
```
- `submit`/`revert`: owner 본인
- `approve`/`reject`/`settle`: `expense_approver` 또는 `admin`
- 첨부 추가/항목 수정/삭제: `작성중` 또는 `반려` 상태에서만
### 마이그레이션
기존 운영 DB 에 신규 컬럼/테이블 적용:
```bash
docker exec -i postgres-db psql -U postgres -d expense_db \
< scripts/sql/expense_db_002_workflow_attachments.sql
```
신규 설치는 `expense_db_init.sql` 하나로 충분 (둘 다 멱등).
### 운영 서버 초기화 (1회)
```bash
# 1) 비밀번호 변수 준비 (셸 히스토리에 남지 않게 환경변수 사용)
read -s -p "expense_app password: " APP_PWD; echo
# 2) PostgreSQL 컨테이너에 DDL 적용
docker exec -i postgres-db psql -U postgres \
-v app_password="$APP_PWD" \
< scripts/sql/expense_db_init.sql
# 3) main-app .env 에 EXPENSE_DB_URL 추가
# EXPENSE_DB_URL=postgresql://expense_app:<APP_PWD>@postgres-db:5432/expense_db
# 4) main-app 재기동
docker compose up -d --build
```
### JSON → DB 마이그레이션
```bash
docker exec -e EXPENSE_DB_URL="$EXPENSE_DB_URL" -it dbx-main \
python scripts/migrate_expense_json_to_db.py \
--json /data/expense.json --dry-run
# 결과 확인 후
docker exec -e EXPENSE_DB_URL="$EXPENSE_DB_URL" -it dbx-main \
python scripts/migrate_expense_json_to_db.py --json /data/expense.json
```
> 멱등 INSERT(`ON CONFLICT DO NOTHING`). 원본 JSON 은 건드리지 않는다.
---
## cupang_db 스키마 / 초기화
DDL: `scripts/sql/cupang_db_init.sql` (멱등). DB·역할(`cupang_app`)·테이블·인덱스·트리거·센터 seed 를 한 번에 생성. **JSON 폴백 없음**`CUPANG_DB_URL` 미설정 시 모듈이 "설정 필요" 안내만 표시.
테이블:
| 테이블 | 용도 |
| --- | --- |
| `cupang_centers` | 입고센터. `active=false` 로 비활성화(사용 중이면 hard delete 금지) |
| `cupang_box_rules` | 제품코드별 박스당 입수량(`units_per_box`). `product_code` UNIQUE |
| `cupang_shipments` | 출고 묶음 헤더 (작성일/출고일/센터입고일/센터/출고방식/상태/작업자/메모) |
| `cupang_shipment_lines` | 출고 라인. `shipment_id` FK ON DELETE CASCADE. `UNIQUE(shipment_id, line_no)` |
`status` 허용값: `작성중`, `출고준비`, `출고완료`, `센터입고완료`, `취소`. 삭제는 기본 soft delete(`status='취소'`).
박스 계산은 서버(`store.compute_boxes`)에서 재계산: `required_boxes = ceil(quantity / units_per_box)`. 클라이언트 계산은 미리보기용.
상품은 `cupang_db` 에 복제 저장하지 않는다. 라인에는 `product_code` + `product_name_snapshot` 만 보존(과거 명칭 보존). 상품 검색은 `itemcode_db` **읽기 전용**(`ITEMCODE_DB_URL`, 미설정 시 수동 입력).
### 운영 서버 초기화 (1회, 사용자 승인 후)
```bash
read -s -p "cupang_app password: " APP_PWD; echo
docker exec -i postgres-db psql -U postgres \
-v app_password="$APP_PWD" \
< scripts/sql/cupang_db_init.sql
# main-app .env 에 추가:
# CUPANG_DB_URL=postgresql://cupang_app:<APP_PWD>@postgres-db:5432/cupang_db
cd /opt/www/main && docker compose up -d --build
```
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. itemcode_db 는 건드리지 않음.
---
## vacation_db 스키마 / 초기화
DDL: `scripts/sql/vacation_db_init.sql` (멱등). DB·역할(`vacation_app`)·테이블·인덱스·트리거·2026 공휴일 seed 를 한 번에 생성. **JSON 폴백 없음**`VACATION_DB_URL` 미설정 시 모듈이 "설정 필요" 안내만 표시.
테이블:
| 테이블 | 용도 |
| --- | --- |
| `vacation_requests` | 휴가 신청(헤더). 종류/기간/시작·종료 구분(full/am/pm)/일수/사유/상태/승인자/반려사유 |
| `vacation_holidays` | 공휴일(`holiday_date` UNIQUE). `is_red=true` 면 달력 빨강 + 일수 계산 제외. 관리자가 settings 에서 추가/수정/삭제 |
| `vacation_balances` | 사용자별 연차(`UNIQUE(user_email, year)`). `total_days` 설정, 사용일수는 승인 휴가 합계로 자동 계산 |
`status` 허용값: `작성중`, `제출`, `승인`, `반려`, `취소`. 워크플로: 작성중/반려 → 제출 → 승인|반려. 삭제는 기본 soft delete(`status='취소'`). 수정은 작성중/반려 상태에서 본인만.
휴가 일수는 서버(`store.compute_days`)에서 재계산: 주말 + `vacation_holidays(is_red)` 제외, 오전/오후 반차 0.5일, 시작/종료 반차는 각 0.5 차감. 클라이언트 계산은 미리보기(주말만 제외)용.
권한: `vacation`(접근) / `vacation_approver`(승인·반려). admin 은 항상 통과. 공휴일·연차 설정은 admin 전용.
### 운영 서버 초기화 (1회, 사용자 승인 후)
```bash
read -s -p "vacation_app password: " APP_PWD; echo
docker exec -i postgres-db psql -U postgres \
-v app_password="$APP_PWD" \
< scripts/sql/vacation_db_init.sql
# main-app .env 에 추가:
# VACATION_DB_URL=postgresql://vacation_app:<APP_PWD>@postgres-db:5432/vacation_db
cd /opt/www/main && docker compose up -d --build
```
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. 공휴일은 연도별로 다르므로 settings 화면에서 추가/수정.
---
## 백업 / 복구 (안전 절차)
### 백업
```bash
# 단일 DB 덤프 (운영 권장)
docker exec -t <postgres_container_name> \
pg_dump -U <user> -F c -d orderlist_db \
> /var/backups/postgres/orderlist_db_$(date +%F).dump
```
### 복구 (덮어쓰기 위험 → 사용자 승인 필수)
```bash
# 1) 신규 DB로 먼저 복구해 검증
docker exec -i <postgres_container_name> \
pg_restore -U <user> -d <new_db_name> < backup.dump
# 2) 검증 완료 후 운영 DB 교체 (필요 시)
```
> `pg_restore --clean`은 기존 객체를 삭제한다. **운영 대상 DB에서 절대 무단 실행 금지**.
---
## .env 관련
- DB 접속 정보(`*_HOST`, `*_PORT`, `*_USER`, `*_PASSWORD`, `*_NAME`)는 모두 `.env`로 관리한다.
- `.env`**Git에 올리지 않는다**. `.env.example`만 커밋한다.
- 비밀값 유출이 의심되면 즉시 회전(비밀번호/키 변경)을 진행한다.
+193
View File
@@ -0,0 +1,193 @@
# Deployment
## 기본 배포 흐름
```text
[Windows 개발 PC]
│ git push (Gitea)
[Gitea Repository]
│ git pull (서버에서)
[Ubuntu Server]
│ .env 확인 → docker compose up --build -d
[Docker]
│ main-app, postgres 컨테이너 실행
[NPM (Nginx Proxy Manager)]
│ 외부 HTTPS 종단 → 내부 :80
[서비스 정상 동작]
```
---
## 운영 서버 배포 경로
운영 서버는 Ubuntu Server. 동일 호스트에 여러 서비스가 있으므로 경로를 분리한다.
| 경로 | 용도 |
| --- | --- |
| **`/opt/www/main`** | **main-app (본 프로젝트) 배포 경로 — 기본** |
| `/opt/dbx-corm` | CORM (CS/발주/반품/코드관리) 서비스 경로 |
| `/opt/dbx-orderlist` | 주문관리/orderlist 서비스 경로 |
> main-app 관련 모든 명령/문서 작성 시 `/opt/www/main` 사용.
> 새로운 경로가 필요하면 **사용자 지시**를 받은 뒤 결정한다.
---
## 사전 준비
- 서버에 Docker, Docker Compose 설치 완료
- Gitea 접근 권한 있는 SSH/HTTPS 인증 설정 완료
- NPM에 호스트 매핑 등록: `dbx.no1king.freeddns.org → http://192.168.0.194:80`
- `.env` 값 확보 (Google OAuth, 세션 키, DB 비밀번호 등)
---
## 최초 배포
```bash
# 1) main-app 배포 경로
sudo mkdir -p /opt/www/main
sudo chown $USER:$USER /opt/www/main
cd /opt/www/main
# 2) 저장소 클론
git clone https://gitea.no1king.freeddns.org/king/dbx-main.git .
# 3) 환경 변수 파일 준비
cp .env.example .env
nano .env # 실제 값으로 수정 (절대 Git에 커밋 금지)
# 4) 빌드 및 백그라운드 실행
docker compose up --build -d
# 5) 로그 확인
docker compose logs -f
```
---
## 업데이트 배포
```bash
cd /opt/www/main
git pull
docker compose up --build -d
docker compose logs -f --tail=200
```
---
## 컨테이너 관리
```bash
docker compose ps # 상태 확인
docker compose restart web # 단일 서비스 재시작
docker compose stop # 중지 (볼륨 유지)
docker compose down # 컨테이너 제거 (볼륨 유지)
# docker compose down -v # ⚠ 볼륨까지 삭제. 운영에서 금지
```
> `docker compose down -v`, `docker volume rm`, `docker system prune -a --volumes`**사용자 승인 없이 실행 금지**.
---
## .env / 비밀값 관리
- `.env`, `.env.local`, `.env.production`**절대 Git에 올리지 않는다**.
- 예시 파일(`.env.example`)만 커밋한다.
- 신규 변수 추가 시 `.env.example`과 본 문서를 함께 갱신한다.
- 비밀값 유출 의심 시 즉시 회전(rotate)한다 (특히 `GOOGLE_CLIENT_SECRET`, `SESSION_SECRET_KEY`, DB 비밀번호).
### 주요 환경 변수
| 변수 | 설명 |
| --- | --- |
| `GOOGLE_CLIENT_ID` | Google OAuth 클라이언트 ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth 클라이언트 보안 비밀 |
| `SESSION_SECRET_KEY` | 세션 쿠키 서명용 랜덤 문자열 |
| `SESSION_COOKIE_SECURE` | HTTPS 환경 `true` / 로컬 HTTP `false` |
| `PUBLIC_BASE_URL` | 외부 접속 주소 (예: `https://dbx.no1king.freeddns.org`) |
| `CS_ORDER_URL` | CS 발주 업무 버튼 이동 주소 |
| `CUSTOMER_ORDER_LIST_URL` | 고객 주문리스트 프로그램 버튼 이동 주소 |
| `*_DB_HOST`, `*_DB_USER`, `*_DB_PASSWORD`, `*_DB_NAME` | 각 PostgreSQL DB 접속 정보 |
| `EXPENSE_DB_URL` | 개인경비 DB DSN (예: `postgresql://expense_app:<pwd>@postgres-db:5432/expense_db`). 미설정 시 JSON 폴백 |
| `CUPANG_DB_URL` | 쿠팡 밀크런 DB DSN (예: `postgresql://cupang_app:<pwd>@postgres-db:5432/cupang_db`). **필수** — 미설정 시 모듈 비활성(설정 필요 안내) |
| `VACATION_DB_URL` | 휴가 관리 DB DSN (예: `postgresql://vacation_app:<pwd>@postgres-db:5432/vacation_db`). **필수** — 미설정 시 모듈 비활성(설정 필요 안내). 권한키 `vacation`/`vacation_approver` |
| `ITEMCODE_DB_URL` | 상품 검색용 itemcode_db 읽기 전용 DSN. 미설정 시 검색 비활성(수동 입력). 테이블/컬럼은 `ITEMCODE_TABLE`/`ITEMCODE_CODE_COL`/`ITEMCODE_NAME_COL`/`ITEMCODE_TYPE_COL` 또는 `ITEMCODE_SEARCH_SQL` 로 지정 |
| `DATA_DIR` | 영구 데이터 경로 (Docker 볼륨 마운트). 첨부파일은 `$DATA_DIR/uploads/expense/{item_id}/` 에 저장 |
---
## 로컬 Docker 테스트
```powershell
# 1) 로컬 env 파일 준비
copy .env.local.example .env.local
# .env.local 을 열어 실제 값으로 수정
# 2) 이미지 빌드 및 실행
docker compose -f docker-compose.local.yml up --build
# 3) 브라우저 확인
# http://localhost:8080
```
> 로컬 OAuth: Google Cloud Console 리디렉션 URI에 `http://localhost:8080/auth/google` 추가 필요.
---
## 복구 절차
### 컨테이너만 깨진 경우
```bash
docker compose up --build -d
docker compose logs -f
```
### DB 데이터가 깨진 경우 (사용자 승인 필수)
1. 즉시 트래픽 차단 (NPM 비활성화 또는 점검 페이지)
2. 가장 최근 백업 확인
3. **새 DB 이름으로 먼저 복구**해 검증
4. 검증 완료 후 운영 DB 교체
5. 복구 후 로그/주문 정합성 점검
> 운영 DB에 직접 `pg_restore --clean`을 실행하지 않는다. `DATABASES.md` 참고.
---
## 위험 명령 (사용자 승인 없이 실행 금지)
| 명령 | 위험 |
| --- | --- |
| `rm -rf` | 파일/디렉터리 영구 삭제 |
| `docker compose down -v` | 볼륨 포함 삭제 → DB 손실 |
| `docker volume rm`, `docker volume prune` | 볼륨 영구 삭제 |
| `docker system prune -a --volumes` | 이미지·네트워크·볼륨 일괄 삭제 |
| `DROP DATABASE`, `DROP TABLE`, `TRUNCATE` | DB 파괴 (`DATABASES.md` 참고) |
| `git reset --hard`, `git push --force`, `git clean -fd` | 작업 내역 손실 |
| 운영 `.env` 덮어쓰기 / 삭제 | 인증·세션 붕괴 |
원칙:
1. 실행 전 현재 상태 확인 명령을 먼저 보여준다.
2. 백업 위치를 명시한다.
3. 사용자 명시 승인 후에만 실행한다.
---
## 점검 체크리스트
- [ ] `docker compose ps` 모든 서비스 `running`
- [ ] `docker compose logs --tail=200` 에러 없음
- [ ] `https://dbx.no1king.freeddns.org` 200 응답
- [ ] Google 로그인 정상 동작
- [ ] PostgreSQL 컨테이너 볼륨 마운트 정상
- [ ] 최근 DB 백업 존재 여부
- [ ] `.env` 미커밋 상태 (`git status`로 확인)
+125
View File
@@ -0,0 +1,125 @@
# Project Overview
## 한 줄 정의
`main-app`은 DBX ERP 시스템의 **메인 프로젝트(허브)** 이다.
주문 수집부터 상품코드 매칭, 재고, CS, 반품, 외부 쇼핑몰 API 연동까지 ERP 운영 전체를 담당한다.
---
## 기능 범위
| 모듈 | 설명 |
| --- | --- |
| 주문관리 | 다채널 주문 수집·통합, 상태 추적, 분석 |
| 상품코드 매칭 | 채널별 상품코드 ↔ 사내 표준 상품코드 매핑, 단품/세트 구성 |
| 재고관리 | 입고/출고/재고 조정, 채널별 재고 동기화 |
| CS관리 | 문의/응대 이력, 발주 업무 트리거 |
| 반품관리 | 반품/교환 접수, 처리, 환불 연계 |
| 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 |
| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 |
| 쿠팡 밀크런 | 쿠팡 출고 일정 관리 (`app/modules/cupang/`) — 월간 달력 / 출고 묶음(헤더+라인) / 박스 입수량 자동계산 / 입고센터 관리 / 엑셀 내보내기. 상품은 `itemcode_db` 읽기 전용 참조 |
| 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 |
### 권한 키 (`MODULE_KEYS`)
| 키 | 종류 | 설명 |
| --- | --- | --- |
| `corm` / `order` | 접근 | 외부 모듈 진입 |
| `expense` / `vacation` / `cupang` | 접근 | 내부 모듈 진입 |
| `expense_approver` | 결재 | 개인경비 승인/반려/정산 |
| `vacation_approver` | 결재 | 휴가 승인/반려 (모듈 미개발) |
`admin` 역할은 모든 권한 자동 부여. 신규 사용자는 관리자 페이지(`/admin`)에서 이메일만으로 등록 가능.
---
## 모듈 디렉토리 규약
신규 업무 모듈은 **별도 디렉토리 한 곳**에 라우터·저장소·템플릿을 모은다.
```
app/modules/<name>/
├─ __init__.py # router export
├─ router.py # FastAPI APIRouter (prefix=/<name>)
├─ store.py # 데이터 저장소 (JSON → 향후 <name>_db)
└─ templates/<name>/ # Jinja 템플릿 (ChoiceLoader 로 검색)
```
신규 모듈 등록 시 `app/main.py` 의:
1. `_MODULE_TEMPLATE_DIRS` 에 템플릿 경로 추가
2. `app.include_router(<name>_router)` 추가
3. `app.state.<name>_store = ...` 등 상태 등록
4. `MODULE_KEYS` (`app/store.py`) 와 `_menu_items_for()` 메뉴 항목 동기화
---
## 외부 연동 대상
- **쇼핑몰**: 카페24, 네이버 스마트스토어
- **통합 관리**: 사방넷
- **물류**: CJ대한통운(CJ Logistics) 외 택배사 API
- **알림**: 문자 발송 API
- **인증**: Google OAuth (Google Workspace 계정 기반)
---
## 기술 스택
| 영역 | 사용 기술 |
| --- | --- |
| Backend | Python, FastAPI |
| DB | PostgreSQL (Docker 컨테이너) |
| 컨테이너 | Docker, Docker Compose |
| Reverse Proxy | Nginx Proxy Manager (NPM) |
| OS | Ubuntu Server (Proxmox VM) |
| 인증 | Google OAuth + 화이트리스트 |
| 저장소 | Gitea (self-hosted) |
---
## 관련 DB
| DB명 | 용도 |
| --- | --- |
| `itemcode_db` | 상품코드, 단품/세트 구성, 매칭 정보 |
| `orderlist_db` | 주문 수집·분석·관리 (구 `orderlist_app`) |
| `return_db` | 반품·교환·CS 데이터 |
| `expense_db` | 개인경비 / 법인카드 / 정산 (`EXPENSE_DB_URL` 미설정 시 JSON 폴백) |
| `cupang_db` | 쿠팡 밀크런 출고/입고센터/박스규칙 (`CUPANG_DB_URL` 필수, JSON 폴백 없음) |
| `vacation_db` | 휴가 신청/공휴일/연차잔여 (`VACATION_DB_URL` 필수, JSON 폴백 없음) |
> 신규 DB가 필요하면 **승인 요청 후** 생성하며, 이름은 `_db`로 끝낸다. 상세는 `DATABASES.md`.
---
## 접속/도메인
| 환경 | 주소 |
| --- | --- |
| 운영 | `https://dbx.no1king.freeddns.org` |
| 로컬 | `http://localhost:8080` |
| Git 저장소 | `https://gitea.no1king.freeddns.org/king/dbx-main.git` |
---
## 허용 사용자 (서버 측 화이트리스트)
`app/main.py``ALLOWED_EMAILS`에서 검사한다.
- king@dbxcorp.co.kr
- julie@dbxcorp.co.kr
- ellen@dbxcorp.co.kr
- bj@dbxcorp.co.kr
Google `hd=dbxcorp.co.kr` 힌트는 클라이언트 편의용이며, **실제 권한 검사는 항상 서버 측**에서 수행한다.
---
## 작업 우선순위 / 원칙
1. 운영 데이터 안전이 최우선. 위험 명령은 사용자 승인 후 실행.
2. 신규 기능보다 기존 데이터 일관성 유지 우선.
3. 외부 API 연동은 실패 재시도와 로깅을 기본으로 한다.
4. 비밀값은 `.env`로만 관리하고 Git에 절대 올리지 않는다.
+90
View File
@@ -0,0 +1,90 @@
# Server Architecture
## 전체 구성도
```text
[Windows 개발 PC]
│ Git push / 파일 수정
[Gitea Repository] (https://gitea.no1king.freeddns.org/king/dbx-main.git)
│ git pull
[Ubuntu Server / Proxmox VM]
├─ Nginx Proxy Manager (NPM)
│ dbx.no1king.freeddns.org → http://192.168.0.194:80
├─ Docker
│ ├─ main-app 컨테이너 (FastAPI / Uvicorn)
│ ├─ PostgreSQL 컨테이너
│ └─ 기타 부속 컨테이너
└─ PostgreSQL Databases
├─ itemcode_db
├─ orderlist_db
└─ return_db
```
---
## 요청 흐름
1. 사용자 브라우저 → `https://dbx.no1king.freeddns.org`
2. NPM(외부 HTTPS) → 내부 HTTP `192.168.0.194:80`
3. 서버 내부 Nginx/Compose 포워딩 → `main-app` 컨테이너 (FastAPI)
4. FastAPI → PostgreSQL 컨테이너(같은 Docker 네트워크) 또는 외부 쇼핑몰/택배 API
---
## 구성요소
| 구성요소 | 설명 |
| --- | --- |
| **Ubuntu Server** | 호스트 OS. 도커 호스트. systemd로 docker.service 관리 |
| **Docker** | 모든 앱·DB는 컨테이너로 운영. Compose 파일 단위 배포 |
| **PostgreSQL** | Docker 컨테이너에서 운영. 데이터는 named volume |
| **Nginx Proxy Manager** | 외부 HTTPS 종단, 인증서 자동 갱신, 호스트명 라우팅 |
| **FastAPI (main-app)** | 메인 ERP 백엔드. 인증·라우팅·외부 API 연동 |
| **Gitea** | 소스 저장소(사내 호스팅) |
---
## 네트워크 / 포트
| 항목 | 값 |
| --- | --- |
| 외부 도메인 | `dbx.no1king.freeddns.org` (HTTPS, NPM 종단) |
| 내부 호스트 | `192.168.0.194` |
| 내부 노출 포트 | `80` (NPM → 컨테이너) |
| 로컬 개발 포트 | `8080` |
| PostgreSQL | Docker 내부 네트워크에서만 접근 (호스트 노출 금지 권장) |
---
## 배포 경로
운영 서버 기준, 아래 중 하나에 클론한다. 상세는 `DEPLOYMENT.md`.
- `/opt/dbx-corm`
- `/opt/dbx-orderlist`
- `/opt/www/main`
> 같은 호스트에 여러 서비스를 운영하므로, 경로는 서비스별로 분리한다.
---
## 인증 흐름
1. 사용자가 `/auth/google` 진입 → Google OAuth 동의 화면
2. 콜백에서 `id_token` 검증 (`hd=dbxcorp.co.kr` 힌트는 참고용)
3. 서버에서 이메일 도메인 + `ALLOWED_EMAILS` 화이트리스트 검사
4. 통과 시 세션 쿠키 발급(`SESSION_SECRET_KEY` 서명)
---
## 운영 시 점검 포인트
- NPM 호스트 매핑이 살아있는가 (`dbx.no1king.freeddns.org → 192.168.0.194:80`)
- `docker compose ps``main-app`, `postgres` 컨테이너 상태
- PostgreSQL volume 마운트 경로와 백업 위치
- `.env` 값 유실 여부 (특히 `GOOGLE_CLIENT_*`, `SESSION_SECRET_KEY`)
+4
View File
@@ -5,3 +5,7 @@ httpx>=0.28
jinja2>=3.1 jinja2>=3.1
itsdangerous>=2.2 itsdangerous>=2.2
python-dotenv>=1.0 python-dotenv>=1.0
psycopg[binary,pool]>=3.2
python-multipart>=0.0.20
openpyxl>=3.1
pillow>=10.0
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""JSON 저장소 → expense_db 마이그레이션.
사용법:
EXPENSE_DB_URL=postgresql://expense_app:<pwd>@<host>:5432/expense_db \
python scripts/migrate_expense_json_to_db.py \
--json /data/expense.json \
[--dry-run]
원칙:
- 멱등(idempotent): id 같으면 INSERT ... ON CONFLICT DO NOTHING.
- JSON 원본은 건드리지 않는다. 검증 완료 사용자가 직접 보존/이관.
- 실패 트랜잭션 롤백 종료.
위험 경고:
- 스크립트는 운영 데이터에 쓰기 작업을 한다. 백업/덤프 실행할 .
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
REQUIRED_KEYS = {
"id",
"owner",
"spent_at",
"category",
"method",
"merchant",
"amount",
"memo",
"status",
}
def load_items(path: Path) -> list[dict]:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
items = data.get("items") if isinstance(data, dict) else None
if not isinstance(items, list):
raise SystemExit(f"형식 오류: {path}'items' 가 리스트가 아닙니다.")
return items
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--json", required=True, help="원본 expense.json 경로")
parser.add_argument("--dry-run", action="store_true", help="DB 에 쓰지 않고 검증만")
args = parser.parse_args()
dsn = os.environ.get("EXPENSE_DB_URL", "").strip()
if not dsn:
raise SystemExit("EXPENSE_DB_URL 환경변수가 설정되지 않았습니다.")
json_path = Path(args.json)
if not json_path.exists():
raise SystemExit(f"파일을 찾을 수 없습니다: {json_path}")
items = load_items(json_path)
print(f"읽음: {len(items)} 건 ({json_path})")
bad = [i for i in items if not REQUIRED_KEYS.issubset(i.keys())]
if bad:
print(f"경고: 필드 누락 {len(bad)}건 — 누락 키는 기본값 채움", file=sys.stderr)
if args.dry_run:
print("dry-run 완료. DB 에 쓰지 않았습니다.")
return 0
import psycopg # type: ignore
inserted = skipped = 0
with psycopg.connect(dsn, autocommit=False) as conn:
with conn.cursor() as cur:
for it in items:
row = (
it["id"],
str(it.get("owner", "")).lower().strip(),
it.get("spent_at") or None,
it.get("category") or "기타",
it.get("method") or "법인카드",
it.get("merchant") or "",
int(it.get("amount") or 0),
it.get("memo") or "",
it.get("status") or "작성중",
it.get("created_at"),
it.get("updated_at"),
)
cur.execute(
"""
INSERT INTO expense_items
(id, owner, spent_at, category, method, merchant,
amount, memo, status, created_at, updated_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,
COALESCE(%s::timestamptz, now()),
COALESCE(%s::timestamptz, now()))
ON CONFLICT (id) DO NOTHING
""",
row,
)
if cur.rowcount == 1:
inserted += 1
else:
skipped += 1
conn.commit()
print(f"완료: insert={inserted}, skip(중복)={skipped}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,21 @@
-- =====================================================================
-- cupang_db 마이그레이션 002 — cupang_shipments.document_no 컬럼 제거
-- =====================================================================
-- 사유: 출고 폼에서 "문서번호" 항목 제거(미사용).
-- 멱등: IF EXISTS. 운영 적용 전 백업 권장(DROP COLUMN 은 되돌릴 수 없음).
--
-- 실행:
-- docker exec -i postgres-db psql -U postgres -d cupang_db \
-- < scripts/sql/cupang_db_002_drop_document_no.sql
--
-- 주의: 이 컬럼에 보관된 값이 있으면 함께 삭제된다. 현재 폼에서 입력받지
-- 않으므로 값이 없거나 NULL 일 가능성이 높다. 확인 후 실행:
-- SELECT count(*) FROM cupang_shipments WHERE document_no IS NOT NULL AND document_no <> '';
-- =====================================================================
\set ON_ERROR_STOP on
\connect cupang_db
ALTER TABLE cupang_shipments DROP COLUMN IF EXISTS document_no;
SELECT 'cupang_db 002 done' AS status;
+199
View File
@@ -0,0 +1,199 @@
-- =====================================================================
-- cupang_db 초기화 스크립트 (PostgreSQL) — 쿠팡 밀크런 모듈
-- =====================================================================
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
--
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
--
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
--
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
-- read -s -p "cupang_app password: " APP_PWD; echo
-- docker exec -i postgres-db psql -U postgres \
-- -v app_password="$APP_PWD" \
-- < scripts/sql/cupang_db_init.sql
--
-- 2) main-app .env 에 연결 정보 등록
-- CUPANG_DB_URL=postgresql://cupang_app:<APP_PWD>@postgres-db:5432/cupang_db
--
-- 3) main-app 재기동
-- cd /opt/www/main && docker compose up -d --build
--
-- 주의:
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
-- - itemcode_db 는 이 스크립트가 건드리지 않는다(상품은 읽기 전용 참조).
-- =====================================================================
\set ON_ERROR_STOP on
-- DB 가 없을 때만 생성
SELECT 'CREATE DATABASE cupang_db ENCODING ''UTF8'' TEMPLATE template0'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cupang_db')
\gexec
-- 앱 전용 로그인 역할 (expense_app 패턴과 동일)
SELECT 'CREATE ROLE cupang_app LOGIN PASSWORD ' || quote_literal(:'app_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'cupang_app')
\gexec
-- 항상 최신 비밀번호로 동기화
SELECT 'ALTER ROLE cupang_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
\gexec
GRANT CONNECT ON DATABASE cupang_db TO cupang_app;
-- cupang_db 컨텍스트로 전환
\connect cupang_db
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
CREATE OR REPLACE FUNCTION cupang_set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ════════════════════════════════════════════════════════════
-- 1) 입고센터
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cupang_centers (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_cupang_centers_updated ON cupang_centers;
CREATE TRIGGER trg_cupang_centers_updated
BEFORE UPDATE ON cupang_centers
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 2) 박스 입수량 규칙
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cupang_box_rules (
id BIGSERIAL PRIMARY KEY,
product_code TEXT NOT NULL UNIQUE,
product_name_snapshot TEXT,
box_name TEXT NOT NULL DEFAULT '쿠팡박스',
units_per_box INTEGER NOT NULL CHECK (units_per_box > 0),
active BOOLEAN NOT NULL DEFAULT TRUE,
memo TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cupang_box_rules_code ON cupang_box_rules (product_code);
DROP TRIGGER IF EXISTS trg_cupang_box_rules_updated ON cupang_box_rules;
CREATE TRIGGER trg_cupang_box_rules_updated
BEFORE UPDATE ON cupang_box_rules
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 2-b) 제품명 카탈로그 (itemcode_db 에서 가져와 등록 → 폼 드롭다운 소스)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cupang_products (
id BIGSERIAL PRIMARY KEY,
product_code TEXT NOT NULL UNIQUE,
product_name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cupang_products_name ON cupang_products (product_name);
DROP TRIGGER IF EXISTS trg_cupang_products_updated ON cupang_products;
CREATE TRIGGER trg_cupang_products_updated
BEFORE UPDATE ON cupang_products
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 3) 출고 묶음 (헤더)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cupang_shipments (
id BIGSERIAL PRIMARY KEY,
created_by TEXT NOT NULL,
document_date DATE NOT NULL,
ship_date DATE NOT NULL,
center_arrival_date DATE NOT NULL,
center_id BIGINT REFERENCES cupang_centers(id),
center_name_snapshot TEXT NOT NULL DEFAULT '',
ship_method TEXT NOT NULL DEFAULT '택배',
outbound_summary TEXT NOT NULL DEFAULT '',
worker TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '작성중',
memo TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cupang_ship_doc_date ON cupang_shipments (document_date);
CREATE INDEX IF NOT EXISTS idx_cupang_ship_ship_date ON cupang_shipments (ship_date);
CREATE INDEX IF NOT EXISTS idx_cupang_ship_arr_date ON cupang_shipments (center_arrival_date);
CREATE INDEX IF NOT EXISTS idx_cupang_ship_status ON cupang_shipments (status);
CREATE INDEX IF NOT EXISTS idx_cupang_ship_center ON cupang_shipments (center_id);
DROP TRIGGER IF EXISTS trg_cupang_shipments_updated ON cupang_shipments;
CREATE TRIGGER trg_cupang_shipments_updated
BEFORE UPDATE ON cupang_shipments
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 4) 출고 라인
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cupang_shipment_lines (
id BIGSERIAL PRIMARY KEY,
shipment_id BIGINT NOT NULL REFERENCES cupang_shipments(id) ON DELETE CASCADE,
line_no INTEGER NOT NULL,
product_code TEXT NOT NULL,
product_name_snapshot TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
box_rule_id BIGINT REFERENCES cupang_box_rules(id),
units_per_box INTEGER,
calculated_boxes INTEGER,
remainder_units INTEGER,
manual_box_text TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (shipment_id, line_no)
);
CREATE INDEX IF NOT EXISTS idx_cupang_lines_shipment ON cupang_shipment_lines (shipment_id);
CREATE INDEX IF NOT EXISTS idx_cupang_lines_code ON cupang_shipment_lines (product_code);
DROP TRIGGER IF EXISTS trg_cupang_lines_updated ON cupang_shipment_lines;
CREATE TRIGGER trg_cupang_lines_updated
BEFORE UPDATE ON cupang_shipment_lines
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 5) 초기 입고센터 seed (멱등: ON CONFLICT DO NOTHING)
-- sort_order 는 목록 순서대로 부여.
-- ════════════════════════════════════════════════════════════
INSERT INTO cupang_centers (name, sort_order) VALUES
('대구3', 1), ('인천32', 2), ('이천1', 3), ('인천42', 4), ('인천26', 5),
('인천16', 6), ('인천28', 7), ('안성8', 8), ('천안8(RC)', 9), ('시흥2', 10),
('인천36', 11), ('MGMH5', 12), ('XRC10(RC)', 13), ('인천14', 14), ('경기광주5', 15),
('경기광주3', 16), ('XRC06(RC)', 17), ('용인1', 18), ('인천30', 19), ('마장1', 20),
('안성4', 21), ('대구6', 22), ('전라광주2', 23), ('창원1', 24), ('고양1', 25),
('동탄1', 26), ('이천4', 27), ('XRC09(RC)', 28)
ON CONFLICT (name) DO NOTHING;
-- ════════════════════════════════════════════════════════════
-- 6) 권한 (cupang_app: CRUD only)
-- ════════════════════════════════════════════════════════════
GRANT USAGE ON SCHEMA public TO cupang_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON
cupang_centers, cupang_box_rules, cupang_products,
cupang_shipments, cupang_shipment_lines
TO cupang_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cupang_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO cupang_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO cupang_app;
SELECT 'cupang_db ready' AS status;
@@ -0,0 +1,36 @@
-- =====================================================================
-- expense_db 마이그레이션 #002 — 결재 워크플로 + 첨부파일
-- =====================================================================
-- 실행:
-- docker exec -i postgres-db psql -U postgres -d expense_db \
-- < scripts/sql/expense_db_002_workflow_attachments.sql
--
-- 멱등. init.sql 도 동일 변경을 포함하므로 신규 설치는 init 하나로 충분.
-- 이미 운영 중인 DB 에만 별도로 적용한다.
-- =====================================================================
\set ON_ERROR_STOP on
-- expense_items 컬럼 추가
ALTER TABLE expense_items
ADD COLUMN IF NOT EXISTS approver_email TEXT,
ADD COLUMN IF NOT EXISTS decided_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS reject_reason TEXT;
-- 첨부파일 테이블
CREATE TABLE IF NOT EXISTS expense_attachments (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL REFERENCES expense_items(id) ON DELETE CASCADE,
owner TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('receipt', 'other')),
filename TEXT NOT NULL,
stored_path TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes BIGINT NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_attach_item ON expense_attachments (item_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_attachments TO expense_app;
SELECT 'migration 002 applied' AS status;
+107
View File
@@ -0,0 +1,107 @@
-- =====================================================================
-- expense_db 초기화 스크립트 (PostgreSQL)
-- =====================================================================
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
--
-- 1) DB / 역할 생성 (superuser 로 1회)
-- docker exec -i postgres-db psql -U postgres -v app_password='<강한비밀번호>' \
-- < scripts/sql/expense_db_init.sql
--
-- 2) main-app .env 에 연결 정보 등록
-- EXPENSE_DB_URL=postgresql://expense_app:<강한비밀번호>@postgres-db:5432/expense_db
--
-- 주의:
-- - 기존 DB 가 있으면 DROP 하지 않는다. CREATE DATABASE 만 IF NOT EXISTS 대체.
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 또는 \set 으로만 전달.
-- - DROP/TRUNCATE 가 필요한 경우 본 스크립트를 수정하지 말고 별도 작업으로 진행.
-- =====================================================================
\set ON_ERROR_STOP on
-- DB 가 없을 때만 생성
SELECT 'CREATE DATABASE expense_db ENCODING ''UTF8'' TEMPLATE template0'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'expense_db')
\gexec
-- 앱 전용 로그인 역할
-- psql 변수(:'app_password')는 dollar-quoted 블록 안에서 치환되지 않으므로
-- DO 블록을 쓰지 않고 \gexec 로 동적 SQL 을 생성·실행한다.
SELECT 'CREATE ROLE expense_app LOGIN PASSWORD ' || quote_literal(:'app_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'expense_app')
\gexec
-- 항상 최신 비밀번호로 동기화
SELECT 'ALTER ROLE expense_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
\gexec
GRANT CONNECT ON DATABASE expense_db TO expense_app;
-- 이제 expense_db 컨텍스트로 전환
\connect expense_db
CREATE TABLE IF NOT EXISTS expense_items (
id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
spent_at DATE NOT NULL,
category TEXT NOT NULL,
method TEXT NOT NULL,
merchant TEXT NOT NULL DEFAULT '',
amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0),
memo TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '작성중',
approver_email TEXT,
decided_at TIMESTAMPTZ,
reject_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 기존 DB 호환: 컬럼이 없으면 추가
ALTER TABLE expense_items
ADD COLUMN IF NOT EXISTS approver_email TEXT,
ADD COLUMN IF NOT EXISTS decided_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS reject_reason TEXT;
CREATE INDEX IF NOT EXISTS idx_expense_owner_spent ON expense_items (owner, spent_at DESC);
CREATE INDEX IF NOT EXISTS idx_expense_status ON expense_items (status);
CREATE INDEX IF NOT EXISTS idx_expense_created_at ON expense_items (created_at DESC);
-- 첨부파일 (영수증/기타). 실제 파일은 파일시스템 저장, 메타만 DB.
CREATE TABLE IF NOT EXISTS expense_attachments (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL REFERENCES expense_items(id) ON DELETE CASCADE,
owner TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('receipt', 'other')),
filename TEXT NOT NULL,
stored_path TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes BIGINT NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_attach_item ON expense_attachments (item_id);
-- updated_at 자동 갱신 트리거
CREATE OR REPLACE FUNCTION expense_set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_expense_set_updated_at ON expense_items;
CREATE TRIGGER trg_expense_set_updated_at
BEFORE UPDATE ON expense_items
FOR EACH ROW EXECUTE FUNCTION expense_set_updated_at();
-- 권한
GRANT USAGE ON SCHEMA public TO expense_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_items TO expense_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_attachments TO expense_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO expense_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO expense_app;
-- 확인용 출력
SELECT 'expense_db ready' AS status;
+167
View File
@@ -0,0 +1,167 @@
-- =====================================================================
-- vacation_db 초기화 스크립트 (PostgreSQL) — 휴가 관리 모듈
-- =====================================================================
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
--
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
--
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
--
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
-- read -s -p "vacation_app password: " APP_PWD; echo
-- docker exec -i postgres-db psql -U postgres \
-- -v app_password="$APP_PWD" \
-- < scripts/sql/vacation_db_init.sql
--
-- 2) main-app .env 에 연결 정보 등록
-- VACATION_DB_URL=postgresql://vacation_app:<APP_PWD>@postgres-db:5432/vacation_db
--
-- 3) main-app 재기동
-- cd /opt/www/main && docker compose up -d --build
--
-- 주의:
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
-- =====================================================================
\set ON_ERROR_STOP on
-- DB 가 없을 때만 생성
SELECT 'CREATE DATABASE vacation_db ENCODING ''UTF8'' TEMPLATE template0'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'vacation_db')
\gexec
-- 앱 전용 로그인 역할 (expense_app / cupang_app 패턴과 동일)
SELECT 'CREATE ROLE vacation_app LOGIN PASSWORD ' || quote_literal(:'app_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'vacation_app')
\gexec
-- 항상 최신 비밀번호로 동기화
SELECT 'ALTER ROLE vacation_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
\gexec
GRANT CONNECT ON DATABASE vacation_db TO vacation_app;
-- vacation_db 컨텍스트로 전환
\connect vacation_db
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
CREATE OR REPLACE FUNCTION vacation_set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ════════════════════════════════════════════════════════════
-- 1) 휴가 신청 (헤더)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS vacation_requests (
id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
owner_name TEXT NOT NULL DEFAULT '',
vacation_type TEXT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
start_half TEXT NOT NULL DEFAULT 'full',
end_half TEXT NOT NULL DEFAULT 'full',
days NUMERIC(5,2) NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '작성중',
approver_email TEXT,
decided_at TIMESTAMPTZ,
reject_reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_vacation_req_owner_start ON vacation_requests (owner, start_date DESC);
CREATE INDEX IF NOT EXISTS idx_vacation_req_status ON vacation_requests (status);
CREATE INDEX IF NOT EXISTS idx_vacation_req_range ON vacation_requests (start_date, end_date);
DROP TRIGGER IF EXISTS trg_vacation_requests_updated ON vacation_requests;
CREATE TRIGGER trg_vacation_requests_updated
BEFORE UPDATE ON vacation_requests
FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 2) 공휴일 (달력 빨강 표시 + 휴가일수 계산 제외 기준)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS vacation_holidays (
id BIGSERIAL PRIMARY KEY,
holiday_date DATE NOT NULL UNIQUE,
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'public',
is_red BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_vacation_holidays_date ON vacation_holidays (holiday_date);
DROP TRIGGER IF EXISTS trg_vacation_holidays_updated ON vacation_holidays;
CREATE TRIGGER trg_vacation_holidays_updated
BEFORE UPDATE ON vacation_holidays
FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 3) 사용자별 연차 잔여 (연도 단위)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS vacation_balances (
id BIGSERIAL PRIMARY KEY,
user_email TEXT NOT NULL,
year INTEGER NOT NULL,
total_days NUMERIC(5,2) NOT NULL DEFAULT 0,
used_days NUMERIC(5,2) NOT NULL DEFAULT 0,
memo TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_email, year)
);
CREATE INDEX IF NOT EXISTS idx_vacation_balances_user_year ON vacation_balances (user_email, year);
DROP TRIGGER IF EXISTS trg_vacation_balances_updated ON vacation_balances;
CREATE TRIGGER trg_vacation_balances_updated
BEFORE UPDATE ON vacation_balances
FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 4) 공휴일 seed — 2026년 대한민국 공휴일 (멱등: ON CONFLICT DO NOTHING)
-- 연도별로 달라지므로 settings 화면에서 추가/수정/삭제 가능.
-- KASI(한국천문연구원) 발표 기준. 새 연도는 settings 또는 본 seed 추가.
-- ════════════════════════════════════════════════════════════
INSERT INTO vacation_holidays (holiday_date, name, kind) VALUES
('2026-01-01', '신정', 'public'),
('2026-02-16', '설날 연휴', 'lunar'),
('2026-02-17', '설날', 'lunar'),
('2026-02-18', '설날 연휴', 'lunar'),
('2026-03-01', '삼일절', 'public'),
('2026-03-02', '삼일절 대체', 'substitute'),
('2026-05-05', '어린이날', 'public'),
('2026-05-24', '부처님오신날', 'lunar'),
('2026-05-25', '부처님오신날 대체', 'substitute'),
('2026-06-06', '현충일', 'public'),
('2026-08-15', '광복절', 'public'),
('2026-08-17', '광복절 대체', 'substitute'),
('2026-09-24', '추석 연휴', 'lunar'),
('2026-09-25', '추석', 'lunar'),
('2026-09-26', '추석 연휴', 'lunar'),
('2026-09-28', '추석 대체', 'substitute'),
('2026-10-03', '개천절', 'public'),
('2026-10-05', '개천절 대체', 'substitute'),
('2026-10-09', '한글날', 'public'),
('2026-12-25', '성탄절', 'public')
ON CONFLICT (holiday_date) DO NOTHING;
-- ════════════════════════════════════════════════════════════
-- 5) 권한 (vacation_app: CRUD only)
-- ════════════════════════════════════════════════════════════
GRANT USAGE ON SCHEMA public TO vacation_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON
vacation_requests, vacation_holidays, vacation_balances
TO vacation_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO vacation_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO vacation_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO vacation_app;
SELECT 'vacation_db ready' AS status;
+1
View File
@@ -0,0 +1 @@
초보자도 쉽게 이해 할 수 있게 코드마다 한글 주석을 쉬운 표현으로 달아줘