219 Commits

Author SHA1 Message Date
king 31eab0d4cb feat(malaysia): MY-1001~1006 신규 세트 malaysia_items 등록 마이그레이션 추가
itemcode_db 세트 BOM은 이미 등록됐으나 malaysia_items(malaysia_stock_db)에
sync 로직 없어 수동 마이그레이션 필요.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 21:55:35 +09:00
king 6d5c3f3181 fix(dispatch): 콤보 BOM 차감 실패 원인별 메시지 분리
기존엔 콤보 BOM 누락 시 (a)set_components 미등록 (b)세트는 등록됐으나
유효 낱개(MT/MX/MZ) 구성품 없음 (c)itemcode_ro 권한/조회 실패 를 한
덩어리로 "콤보 BOM 이 없어 차감할 수 없습니다"로만 알려, 실제 원인을
못 짚어 같은 에러가 반복됐다.

- itemcode.py: set_codes_present() 추가 — set_components 의 MY- 세트
  코드 전체를 구성품 유효성 무관하게 조회. (a)/(b) 구분용.
- router.py stock_deduct: 누락 콤보를 등록여부로 분기, 권한/조회 실패는
  별도 안내. 정확한 원인과 조치를 메시지에 노출.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:08:06 +09:00
king b440c0eed0 feat(dispatch): 검색 바를 「+ 새 업로드」오른쪽으로 이동(30px 간격)
별도 줄/카드 제거. _nav 행에 인라인 배치(배치 목록 페이지에서만).
결과 드롭다운은 그대로 절대위치 오버레이.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:20:20 +09:00
king 77ce86f5ed feat(dispatch): 검색 결과 가독성 — 송장 조회 링크·택배사 배지·줄분리
- api_search: 결과에 courier_tracking_url 주입(송장 클릭 시 조회 페이지)
- batches.html: Order/Pkg/송장 한 줄씩, 글자 확대, 송장번호·날짜·쇼핑몰
  굵게, 송장 오른쪽 배송사 배지, 송장번호 클릭 → 배송사 조회 새 탭

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:17:04 +09:00
king 718b5e6d8f feat(dispatch): 받는 사람 이름도 검색 + 검색창 컴팩트 드롭다운
- search_parcels: recipient_name ILIKE 추가(이름·주문·패키지·송장 4개 필드)
- batches.html: 큰 카드 → 한 줄 입력창(🔎+clear), 결과는 절대위치
  드롭다운으로 본문 안 밀리게. 바깥 클릭·Esc 닫기, 이름 매칭 강조

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:11:45 +09:00
king 6dc14a7350 feat(dispatch): 주문/패키지/송장 번호로 받는 사람 찾기 검색
배치 목록 상단에 검색 박스 추가. 주문번호·패키지번호·송장번호 일부만
입력하면 전체 출고 배치에서 해당 박스의 받는 사람(이름·전화·주소)과
소속 배치를 찾아준다.

- db.search_parcels: 3개 식별자 ILIKE 부분일치 + 배치 조인(파라미터 바인딩)
- GET /dispatch/api/search: 2글자 이상, dispatch 권한 가드
- batches.html: 디바운스 검색 UI, 매칭 강조, 한/영 토글 연동

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:07:00 +09:00
king 26d0753579 fix(dispatch): 사방넷 코드 맵 비면 공란 파일 대신 명확한 에러
사방넷 코드 맵이 통째로 비어 상품코드[필수] 열이 전부 공란이 되는 경우,
조용히 잘못된 파일을 주지 않고 single_items/set_items SELECT 권한/등록
확인을 안내하는 422 를 반환.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:07:17 +09:00
king 85dffb0178 fix(dispatch): 사방넷 출고 엑셀 — 세트 BOM 없으면 조용히 미분해 파일 대신 명확한 에러
세트(MY-)인데 set_bom 에서 BOM 을 못 찾으면 낱개 분해가 안 된 엑셀이
조용히 만들어졌다. 이제 어떤 세트가 분해 불가인지와 원인(itemcode_ro
set_components SELECT 권한/누락 BOM)을 422 로 알린다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:03:45 +09:00
king b74d2be6d3 feat(project): 좌측 트리를 클라이언트 렌더 → 업무 추가/수정/삭제 즉시 반영
- 사이드 프로젝트 트리를 tasks 로부터 JS 로 재렌더(renderTree)
- refreshActiveView 에서 트리도 갱신 → 새로고침 없이 좌측에 바로 표시
- 새 업무 추가 시 해당 프로젝트 자동 펼침
- projectsMeta 에 created_by 추가(트리 삭제 권한용)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:58:37 +09:00
king 0822baf5b7 style(project): 모든 댓글 표시를 💬 이모지로 통일
달력/보드/리스트 말풍선과 업무 모달 댓글 헤더 아이콘을 material 폰트
대신 💬 이모지로 변경(타임라인과 일관).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:31:31 +09:00
king 6e0180944e feat(project): 담당자·댓글 작성자 아이콘을 구글 프로필 이미지(원형)로 표시
- 라우터: 이메일→구글 picture URL 아바타 맵을 pj-data 로 전달
- 보드 카드/리스트 담당자, 댓글 작성자에 원형 프로필 이미지 표시
- 이미지 없으면 기존 기본 아이콘 폴백. referrerpolicy=no-referrer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:26:55 +09:00
king 7c5a62ecc1 fix(project): 타임라인 댓글 아이콘(이모지) + 확대구간 저장 안되던 문제
- vis 가 HTML 정화 → 아이콘 폰트 대신 💬 이모지로 댓글 수 표시
- 저장된 구간을 생성 옵션 start/end 로 주입해 autofit 덮어쓰기 방지,
  rangechanged 는 byUser 일 때만 저장

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:20:46 +09:00
king e4fb945474 feat(project): 댓글 있는 업무에 말풍선 아이콘 표시(달력·타임라인·보드·리스트)
- list_tasks/get_task 에 comment_count 서브쿼리 추가
- 달력 막대/타임라인 항목/보드 카드/리스트 업무열에 댓글 수 말풍선
- 댓글 로드 시 메모리 카운트 동기화

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:17:48 +09:00
king e817dd75d5 feat(project): 리스트 행 배경에 프로젝트 색 연하게 + 타임라인 확대구간 기억
- 리스트: 각 행 배경을 프로젝트 색 8%로 칠함
- 타임라인: 보던 확대/이동 구간을 localStorage(pj_tl_window)에 저장,
  재렌더·탭전환 시 복원(초기화 방지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:13:00 +09:00
king f8f4cded66 feat(project): 업무·프로젝트 삭제를 '만든 사람'만 허용 + 경고
- 서버: api_delete_task/api_delete_project 가 created_by 일치자만 허용
  (슈퍼관리자 예외, created_by 없는 과거 데이터는 관리자 허용). 403 반환.
- 클라이언트: 삭제 버튼 클릭 시 만든 사람이 아니면 경고창 후 중단
  (created_by 를 트리 삭제버튼·홈 카드·업무 객체로 전달)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:10:24 +09:00
king 523b9b3150 fix(project): 타임라인 배경을 프로젝트 시작/마감일 기준으로 표시
배경(전체 일정)을 업무 최소~최대가 아니라 프로젝트 자체 start_date~
due_date 로 칠한다. 업무가 그 밖에 있으면 범위를 넓혀 가리지 않게 유지.
projects_meta 에 start_date/due_date 추가.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:59:57 +09:00
king c0ca3cf64f fix(project): 달력 막대 줄 순서를 주마다 일정하게 (전역 레인 + 주별 압축)
업무마다 한 달 전체 기준 고정 레인을 배정하고, 각 주는 존재하는
레인만 0,1,2…로 압축해 표시. 여러 주에 걸친 막대의 상하 순서가
주가 바뀌어도 유지됨(틱톡/쇼피 순서 뒤바뀜 수정).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:54:14 +09:00
king 92ee239904 feat(project): 달력 구글 캘린더식 개편 — 칩 정리·행 제한(+N)·부드러운 드래그
- 막대를 클라이언트(JS)가 렌더 → 주별 레인 배치, 표시 행 MAX_LANES(3) 제한
- 넘치면 '+N 더' 칩, 클릭 시 그날 업무 팝오버(클릭하면 업무 모달)
- 막대 라벨은 제목만(프로젝트는 색으로 구분), 전체는 툴팁
- 드래그 이동: 낙관적 업데이트(즉시 재렌더, 새로고침 제거) + 실패 시 롤백
- 주 셀 높이 확대, 칩 스타일 정리

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:48:28 +09:00
king 17f2d56231 feat(project): 홈 프로젝트 카드 수정 버튼 + 완료 프로젝트 비활성/숨기기
- 카드에 수정(연필) 버튼: 이름·설명·기간·색상·완료 토글 + 삭제 (관리자)
- 프로젝트 상태에 'completed' 추가, update_project status 허용
- 완료 프로젝트: 흐리게 비활성 + 홈 목록 맨 끝으로 정렬
- '완료 N개 숨기기' 토글 버튼(localStorage 기억)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:34:09 +09:00
king e36fcb1a34 feat(project): 달력·타임라인·보드·리스트 4개 뷰를 전체 프로젝트 기준으로 표시
- project_page 가 접근 가능한 모든 프로젝트의 업무를 집계(all_tasks)
- 달력: 전 프로젝트 bar(라벨에 [프로젝트명] 유지)
- 타임라인: 프로젝트별 행(그룹) + 행마다 전체기간 배경 + 업무 막대
- 보드: 단계 '이름' 기준 컬럼으로 전 프로젝트 묶음, 카드에 프로젝트 배지
       드롭 시 해당 업무 프로젝트의 동일 이름 단계로 이동
- 리스트: '프로젝트' 컬럼 추가
- 모달 단계 선택은 해당 업무 프로젝트의 단계로 채움(타 프로젝트 편집 대비)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:23:49 +09:00
king d89aa349eb feat(project): 내업무 트리·벨 애니메이션·타임라인 그룹·리스트 정렬/리사이즈·검정 탭
- 홈 '내 업무': 프로젝트별 그룹 트리, 업무 아래 날짜 기간 작은 글씨
- 알림 벨: 미읽음 있으면 종 흔들림 애니메이션 + 숫자 배지(기존)
- 타임라인: 프로젝트 전체 기간을 연한 색 배경으로 칠하고(그룹 행) 그 안에
  업무 막대 표시 → 업무명에 프로젝트명 불필요
- 리스트: 컬럼 헤더 클릭 정렬(오름/내림 아이콘), 컬럼 너비 드래그 조절+
  localStorage 저장, 헤더 글씨 크고 굵게
- 상단 뷰 탭 선택 시 검은 배경 흰 글씨

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:04:45 +09:00
king 88ce4ee378 fix(project): 시간 입력란을 체크 전엔 비활성화
기존엔 pj-time-row 를 hidden 으로 숨겼으나 .pj-form-row{display:flex} 가
[hidden] 을 이겨 항상 보였다. 숨김 대신 입력란을 disabled 로 두고 '시간
지정' 체크 시 활성화하도록 변경(회색 비활성 → 체크 시 활성).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:41:52 +09:00
king 7c85c147e0 feat(project): 시간 입력란 클릭 시 시간 피커 즉시 열기
시작/마감 시간 input(type=time) 클릭 시 showPicker() 로 드롭다운 즉시
표시. 시계 아이콘을 정확히 누르지 않아도 됨.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:37:07 +09:00
king 133bfc55b8 feat(project): 업무 시간 지정(종일 기본) + 타임라인 시간 반영
- tasks 에 start_time/due_time TIME 컬럼 추가(마이그레이션 003). NULL=종일
- 업무 모달에 '시간 지정' 체크박스 → 체크 시 시작/마감 시간 입력란 표시.
  미체크면 종일로 저장(시간 null)
- store.validate_time(HH:MM), db create/update + TIME 직렬화('HH:MM')
- 타임라인: 시간 있으면 date+time 으로 정확히 배치, 없으면 종일

DB: scripts/sql/project_db_003_task_times.sql 서버서 적용 필요.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:34:52 +09:00
king a6adb5fc41 fix(project): 드래그 후 모달 재오픈 방지 + 타임라인 한글 날짜·휠 좌우이동
- 드래그 이동 후 location.reload() 시 URL 의 ?task= 가 남아 업무 모달이
  다시 뜨던 문제 수정: ?task 처리 직후 history.replaceState 로 파라미터 제거
- 타임라인 날짜 라벨 한글화(format minor/major 함수: 'M월 D일 (요일)', 'YYYY년 M월')
- 타임라인 마우스 휠 = 좌우 이동(horizontalScroll), 확대/축소는 Ctrl+휠

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:27:41 +09:00
king 03916a61b1 feat(project): 서브프로젝트 제거, 사이드 트리를 프로젝트→업무 2단계로
요구에 따라 서브프로젝트 개념을 UI 에서 제거. 좌측 트리를 프로젝트(최상위)
→ 그 안의 업무 2단계로 재구성:
- 프로젝트 노드(folder) 펼치면 소속 업무가 체크 아이콘으로 표시
- 업무 클릭 → /project/p/{id}?task={tid} 로 이동 후 해당 업무 모달 자동 오픈
- 완료 업무는 취소선. 트리에서 프로젝트 삭제(관리자)
- 과거 parent_id 서브프로젝트는 트리/홈에서 숨김(최상위만 노출)
- 홈 카드의 '서브 N' 칩 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:20:28 +09:00
king a22bb1e043 feat(project): 업무추가·알림벨을 상단바로 이동, 보드 단계추가 제거
- erp_base 토픈바에 {% block topbar_actions %} 추가
- 프로젝트 페이지: 알림벨 + '업무 추가' 를 탭 줄 → 상단 메뉴 오른쪽(사용자
  칩 옆)으로 이동. 탭 줄은 뷰 탭만 남김
- 보드 뷰의 '+ 단계 추가' 트레일링 컬럼 제거(단계 추가 기능 삭제)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:13:12 +09:00
king 64dd7445bc feat(project): 아사나식 프로젝트/서브프로젝트 트리 사이드바
좌측 사이드바를 직속 서브 평면 목록 → 전체 프로젝트 재귀 트리로 교체.
- 접기/펼치기 캐럿(localStorage 로 펼침 상태 저장, 활성 프로젝트 상위 자동 펼침)
- 프로젝트 색상 아이콘, 들여쓰기(depth), 활성 프로젝트 강조
- hover 시 서브추가(+)/삭제(×) 액션(관리자·멤버, 서버 권한 검증)
- 상단 '새 프로젝트'(관리자). Material Symbols 아이콘(self-host) 사용.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:09:17 +09:00
king 57b4b37468 feat(project): 달력 bar 라벨에 프로젝트명·담당자 표기
bar 라벨을 제목만 → '[프로젝트명] 제목 · 담당자(아이디)' 로 확장.
서브프로젝트 업무도 한 달력에 섞이므로 프로젝트 구분에 유용.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:59:44 +09:00
king 105e036573 fix(project): 달력 드래그를 포인터 기반으로 재구현
HTML5 네이티브 DnD 가 절대배치 grid 오버레이 달력에서 dragstart 를 잡지
못해(콘솔 에러 없이 무동작) 드래그가 안 됐다. mousedown→mousemove→mouseup
포인터 기반으로 직접 구현:
- 5px 이상 움직이면 드래그 시작, 고스트 라벨이 커서 따라다님
- 드래그 중 bar pointer-events 해제 → elementFromPoint 가 날짜 셀 반환
- 놓은 칸의 날짜로 기간 유지 이동, 안 움직이면 클릭=모달
- 네이티브 draggable 속성 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:57:56 +09:00
king 7a85367db8 fix(project): 달력 드래그 불가 수정 — bar 를 a→div 로 교체
업무 bar 가 <a> 앵커라 브라우저가 'link drag'(href 없음)로 처리해
드래그가 시작되지 않았다. <div> + 서버 렌더 draggable 속성으로 교체해
요소 드래그가 정상 시작되도록 함.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:48:17 +09:00
king 5f85826965 feat(project): 댓글 수정 기능 + 달력 드래그 드롭 수정
- 댓글 수정: PUT /api/comments/{id}(본인·관리자), 모달서 인라인 편집(textarea)
- 달력 드래그 드롭이 안 되던 문제 수정: 드롭 대상을 day 셀 대신 주(week)
  단위로 받고 마우스 X 로 날짜 칸을 계산. bar 오버레이/pointer-events 간섭
  없이 안정적으로 드롭됨. dragover 에서 dropEffect=move 명시.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:42:04 +09:00
king 15eabd0bbf feat(project): 달력에서 bar 드래그로 일정 이동
서버 렌더 달력의 업무 bar 를 다른 날짜로 드래그하면 기간을 유지한 채
일정 이동(시작일 앵커, 없으면 마감일). 드롭 시 PUT /api/tasks/{id} 로
start/due 갱신 후 새로고침. 드래그 중엔 bar pointer-events 해제해 날짜
셀이 드롭을 받도록 하고, 드롭 대상 칸은 하이라이트.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:37:19 +09:00
king 776f655db4 feat(project): 댓글·첨부·단계편집·알림센터 + 라이브러리 self-host
아사나식 기능 4종 추가:
- 댓글: 업무 모달에서 등록/삭제, 관련자 인앱 알림(task_comments)
- 첨부: 업로드(20MB)/다운로드/삭제, 파일은 DATA_DIR/project/<task_id>/,
  DB엔 메타만(task_attachments)
- 단계 편집: 보드 칸반에서 이름변경/완료토글/순서이동/삭제/추가
- 알림센터(인앱): 배정·완료·댓글 시 수신자별 알림, 벨 미읽음 배지,
  /project/inbox 페이지, 읽음/모두읽음(project_notifications)

라이브러리 self-host: vis-timeline + Material Symbols 를 static/vendor/ 로
내려받아 CDN 의존 제거.

DB: scripts/sql/project_db_002_attachments_notifications.sql (서버서 적용 필요).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:32:00 +09:00
king ef7f76821d feat(admin): 슈퍼관리자가 admin 의 개별 프로그램 접근 제어
기존엔 admin 역할이면 모든 모듈을 무조건 전체 ON+잠금이라 슈퍼관리자도
admin 의 특정 프로그램 접근을 끌 수 없었다.

- _normalize_modules: admin 강제 전체 ON 덮어쓰기 제거 → 저장된 칩 상태 존중
- has_module: admin 단축 통과 제거, 슈퍼관리자만 항상 전체. admin 은 칩 따름
- admin.html: admin 칩 잠금 해제(슈퍼관리자만 잠금), 역할→admin 전환 시
  편의상 전체 ON(잠금 아님)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:15:51 +09:00
king 4008eee088 feat(admin): 권한 열을 칩 토글로 — 모듈 늘어도 가로 안 넓어짐
모듈마다 토글 열을 두던 방식은 모듈이 늘수록 표가 가로로 무한 확장됐다.
모듈 열 전체를 단일 '접근 권한' 셀의 칩(pill) 토글로 교체. 칩이 줄바꿈되어
모듈이 늘어도 세로로만 늘고 한 화면에서 관리 가능. 칩 클릭=토글, admin/슈퍼는
잠금(전체 ON). 승인 권한 칩은 켜지면 파랑으로 구분.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:12:14 +09:00
king 8ed1263bef feat(project): 관리자 페이지 접근 토글 + 멤버 배정 등록사용자 자동목록
- project 를 MODULE_KEYS 에 추가 → 관리자 페이지에 '프로젝트 관리' 토글
  자동 생성. 직원별 접근 권한 부여(admin 자동, 일반은 기본 OFF).
- 모듈 진입을 has_module('project') 로 게이트(기존 전원허용 철회).
- 멤버 배정 모달: GET /project/api/assignable-users 로 project 권한 보유
  등록 사용자를 드롭다운 자동 목록화(타이핑 제거). 이미 배정된 사람 제외.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:08:57 +09:00
king d60ae9a210 feat(project): 담당자는 아이디만 표기 + 구글 머티리얼 아이콘
담당자 표시를 이메일 아이디(@ 앞)로 통일. 아바타를 letter 원형에서
구글 Material Symbols account_circle 아이콘으로 교체(멤버 목록·보드 카드·
모달 선택지·리스트 뷰).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:04:33 +09:00
king 78f212fffd feat(project): 달력을 휴가 모듈식 월간 그리드로 교체
FullCalendar 제거. 휴가 모듈과 동일한 서버 렌더 월간 달력(구글식 bar
레인 배치)로 변경. 업무 start~due 기간을 bar 로 표시, 클릭 시 모달.
?y=&m= 로 월 이동, 프로젝트 색상 반영, 완료 업무는 회색+취소선.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:00:48 +09:00
king c192d61c71 feat(project): 아사나식 프로젝트 관리 모듈 추가
- project_db 신규(projects/members/stages/tasks/comments/activity, project_app CRUD)
- 프로젝트/서브프로젝트(self-FK), 업무·진행단계(칸반), 멤버 배정
- 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 토글 + 보드/리스트
- 진입은 로그인 회사 직원 전원, 생성/배정은 관리자 전용
- 업무 배정·완료 시 관리자 메일 알림(app/mail.py, SMTP_* env, 미설정 시 skip)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:22:05 +09:00
king 8d7fe01c2d feat(dispatch): 메뉴얼 오더 플랫폼 추가(엑셀 단독)
- 플랫폼 Manual 추가. 엑셀 첫 시트(Receiver Name/Phone/Address/Product Name/
  Item Code/Quantity)를 전용 파서로 읽어 1행=1박스 생성
  · 받는 사람 정보가 엑셀에 직접 있어 PDF 불필요(라벨 슬롯 없음)
  · 묶음키 없음 → 행마다 별도 박스
- 업로드 화면: Manual 선택 시 라벨 PDF 칸 숨김 + 안내문
- 출고 카드: Order ID/Tracking 은 값 있을 때만 표시(Manual 은 숨김),
  Manual 은 상품명 표기(받는사람/전화/주소/상품명/수량)
- 재고 차감/사방넷 출고는 기존 집계 로직 그대로(콤보 _N 분해 포함)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:16:19 +09:00
king c4abafd883 fix(malaysia): 이동이력 구분(낱개/콤보) 영문 번역 누락 수정
- malaysia.js KO_EN 사전에 구분/낱개/콤보 추가
- 구분 헤더 + 낱개/콤보 배지에 i18n 클래스 부여(영문 모드 번역 적용)
- malaysia.js 캐시버전 20260622a

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:59:05 +09:00
king d3b06e1695 fix(malaysia): 이동이력 낱개/콤보를 '입력 방식' 기준으로 구분
- 콤보 출고는 콤보 줄(set_movement)로만 표시. 분해된 구성 낱개는 숨김
  · create_set_out 구성 낱개 ref_type 을 SET_COMPONENT_REF('set')로 고정
  · 이동이력 낱개 목록에서 ref_type=SET_COMPONENT_REF 제외
- 직접 낱개 입력만 '낱개', 콤보 입력만 '콤보'로 표기(재고 계산엔 영향 없음)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:20:47 +09:00
king 2ab38c7dbd feat(malaysia): 이동 이력에 낱개/콤보 분리 표시
- set_movement(콤보 출고 원장)을 이동이력에 병합, 구분 컬럼(낱개/콤보) 추가
- db.list_set_movements (동일 필터: 창고/종류/날짜)
- 콤보 줄은 set_code 를 Item 으로 표시 → 콤보 몇 개 나갔는지 확인 가능

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:14:45 +09:00
king b583627bd3 feat(dispatch): 말레이시아 재고 차감 버튼(날짜 1회성)
- 달력 다운로드 아래 '말레이시아 재고 차감' 버튼. 선택 날짜 출고를
  말레이시아 재고관리에 OUT 으로 반영(movement_date=해당 날짜)
  · 낱개(MT/MX/MZ)=낱개 OUT, 콤보(MY-)=세트 OUT(재고관리가 BOM 분해)
  · _N 배수 반영, MD-(뚜껑) 제외
- 날짜당 1회만: dispatch_stock_deductions 마커로 재차감 차단(이중 출고 방지)
  · 마커 선점 후 반영, 콤보 BOM 누락은 사전검증으로 차단
- export.split_singles_combos, db 차감 가드 메서드, 라우터 POST /dispatch/stock-deduct
- 스키마: dispatch_stock_deductions (init + 마이그레이션 dispatch_add_deductions.sql)
- 이미 차감된 날짜는 버튼 비활성 + 안내

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:07:37 +09:00
king 27e2c17606 fix(dispatch): '재고' → '사방넷 출고' 명칭 정정
- 다운로드 파일은 재고가 아니라 사방넷에 올려 출고로 잡는 파일(업로드 시 재고 차감).
  달력 카드 제목/안내/버튼 문구를 출고로 정정, 파일명 _sabangnet_outbound.xlsx
- 컬럼/집계 로직은 동일

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:28:38 +09:00
king 9221efe925 feat(dispatch): 날짜별 사방넷 재고 엑셀 다운로드 + 달력 UI
- 배치 목록을 좌(리스트)/우(달력) 2단 배치
- 달력에서 배치 있는 날짜 클릭 → 그날 전 배치 출고를 낱개로 분해해 다운로드
  · _N 배수 × 주문수량 × 콤보 BOM 구성수량, MD-(뚜껑) 제외
  · 사방넷 4열 템플릿(A 상품코드[필수]=사방넷코드 / B 가용수량=수량 /
    C 불용수량=0 / D 바코드=아이템코드), 주황 헤더, xlsx
- db.stock_aggregate_for_date, export.explode_to_singles/build_stock_xlsx
- 라우터 GET /dispatch/stock-export?date=YYYY-MM-DD

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:25:05 +09:00
king 2c7f98ef30 chore(dispatch): 쇼피 플랫폼 로고 이미지 교체
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:54:55 +09:00
king 0204b418e6 style(dispatch): 플랫폼 로고 크기 통일(쇼피 확대) + 배치 표 글자 키움
- shopee 로고는 여백이 많아 작아 보여 height 34px(tiktok 26px)로 맞춤
- 배치 목록 td 15px / th 13px 로 가독성 향상

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:50:32 +09:00
king 1d6a523c1f feat(dispatch): 배치 목록 플랫폼을 로고 아이콘으로 표기
- shopee.png / tiktok.png 추가, 플랫폼 칸에 아이콘(미매칭은 텍스트 폴백)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:48:14 +09:00
king cdc3e903d7 style(dispatch): 생성시각 표시에서 T·타임존 오프셋 제거
- 2026-06-22T15:17:59+09:00 → 2026-06-22 15:17:59 (현지 입력 시각 그대로)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:53:55 +09:00
king 425556aea8 fix(dispatch): 배치명 자동 기본값('출고')도 영문 모드에서 치환
- 저장된 배치명은 데이터라 정적 번역 불가 → data-raw 요소를 _nav 공용 JS가
  영문 모드에서 '출고'→'Dispatch', '(이름없음)'→'(no name)' 치환
- 배치 목록 배치명 + Kagayaku 전달 화면 제목 적용

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:51:26 +09:00
king 1d3b97c8fc feat(dispatch): 한글/영문 토글(말레이시아 직원용)
- _nav 에 KO/EN 토글 버튼 + 공용 JS(data-ko/data-en 텍스트, data-*-ph placeholder
  교체, localStorage 저장 → 전 페이지 공통, dsp:lang 이벤트 발행)
- batches/new/detail/picking/handover 정적 라벨 이중언어화
- 동적 텍스트도 언어 반영: 완료 N/M 라벨, 일괄 토글 확인창, 업로드 안내/파일 라벨
- 카드 작업 버튼은 기존대로 한/영 동시 표기 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:47:32 +09:00
king 90524100ed style(dispatch): 일괄 버튼 색을 카드 상태 버튼과 동일하게(.dsp-btn-status)
- 모두 라벨부착/모두 Kagayaku 전달 버튼이 송장 카드 버튼처럼
  ON 초록(#16a34a)/OFF 회색. erp-btn 대신 .dsp-btn-status + .on 토글

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:36:32 +09:00
king 46d23a3323 feat(dispatch): 일괄 버튼을 토글로 변경(전부 완료면 해제)
- 모두 라벨부착/모두 Kagayaku 전달 버튼이 현재 상태를 보고 토글
  · 전 박스 완료면 → 모두 해제, 아니면 → 모두 완료
- 버튼 색으로 현재 상태 표시(전부 완료 시 초록), 개별 토글/로드 시 동기화
- bulk 라우터에 value 파라미터 추가(완료/해제)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:33:37 +09:00
king a1dd9bed4a feat(dispatch): 일괄 완료 버튼(모두 라벨부착/모두 Kagayaku 전달) + 검색바 축소
- 출고 작업 툴바 우측에 일괄 버튼 2개. 배치 내 전 박스 상태를 완료로 일괄 설정
- db.bulk_set_status: 변경된 박스만 로그(set 기반 INSERT), 화이트리스트 검증
- 라우터 POST /batches/{id}/bulk
- 검색 입력칸 폭 축소(flex 0 1 240px)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:29:55 +09:00
king ef4658fb1d feat(dispatch): 출고 엑셀 수량에 _N 배수 반영 + 아이템코드 base 표기
- seller_sku 끝 _<숫자>는 '같은 상품 N개' → 주문 수량을 qty×N 으로 계산
- 아이템 코드 칸은 접미사 제거한 base 코드(MT-0320) 표기
- 상품명은 base 코드로 itemcode_db 조회(기존)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:23:00 +09:00
king 9c7f88a09d style(dispatch): SPX 로고 크기 축소(틱톡 로고와 시각 크기 맞춤)
- SPX 로고는 가로로 넓어 같은 높이에서 더 커 보여 height 30px 로 조정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:14:20 +09:00
king d9acd86573 fix(dispatch): SPX 로고를 png로 교체(svg 배경 불투명)
- spx.svg 제거, spx.png 추가(투명 배경). png 가 svg 보다 우선 사용됨

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:12:31 +09:00
king cee0c65a01 feat(dispatch): SPX 로고(svg) + 출고 엑셀 상품명 DB 조회
- spx.svg 추가(택배사 SPX 로고). courier 로고 슬러그 spx 가 사용
- 출고 엑셀 상품이름을 아이템코드(seller_sku)로 itemcode_db 에서 조회해 채움
  · malaysia_itemcode.name_map(single_items+set_items) 재사용
  · 변형 접미사(_숫자) 제거 후 재조회, 못 찾으면 파싱된 상품명 폴백

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:10:16 +09:00
king 95561270a9 fix(dispatch): 받는 사람 정보는 라벨 PDF만 출처(TikTok 포함)
- TikTok xlsx 의 가려진 이름/주소가 우선 채워져 PDF 가 무시되던 문제 수정
- COLUMN_ALIASES 에서 recipient_* 매핑 제거 → xlsx PII 미사용
- 머지 시 PDF 값이 있으면 무조건 덮어씀(PDF authoritative)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:58:21 +09:00
king 163537fd96 feat(dispatch): 배치 삭제 버튼(슈퍼 관리자 전용)
- 배치 목록에 삭제 버튼 추가, is_super 일 때만 표시 + 확인창
- 삭제 라우터 권한을 admin → 슈퍼 관리자 전용으로 강화

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:53:30 +09:00
king 106cf70922 feat(dispatch): 라벨 PDF에서 받는 사람(이름/전화/주소) 추출
xlsx 엔 받는 사람이 없거나(Shopee) 가려져(TikTok) 라벨 PDF 를 출처로 사용.
업로드 시 Order ID 로 박스와 조인해 채운다(하이브리드).

- pdf_parser.py 추가 (pdfplumber)
  · Shopee SPX 라벨: 2단 중 왼쪽 열만 읽어 Recipient 블록 추출(전화 미표시)
  · TikTok 라벨: 배송사별 양식 대응 — To <이름> (+60)…(Ninja), Receiver <이름>(NDD)
  · 주소 영역에 섞인 10자리+ 바코드 숫자 제거(우편번호 5자리 보존)
  · PDF 없거나 파싱 실패해도 업로드 진행(PII 만 빈 값)
- router: 라벨 PDF 파싱 결과를 parcels 에 머지 후 저장
- requirements: pdfplumber>=0.11 (Docker 재빌드 필요)
- new.html: PDF 가 받는 사람 출처임을 안내(권장)
- .gitignore: docs/samples/ (실제 고객 PII PDF — 커밋 금지)
- 문서 갱신

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:45:35 +09:00
king 691eecee52 fix(dispatch): Shopee Packing List xlsx 파싱 추가
- 헤더 별칭에 underscore형(order_sn, tracking_number) 추가
- Shopee 는 SKU/수량이 product_info 한 셀에 묶여 있어 전용 파서 추가
  · store.parse_shopee_product_info: [n] 단위 상품 분해, SKU/상품명/수량 추출
  · parser._parse_shopee: 1행=1주문=1박스, 상품 펼쳐 박스로 묶음, 배송사 SPX 추론
- parse_export(platform) 로 Shopee/일반 경로 분기, 라우터가 platform 전달
- 이름/주소/전화는 xlsx 에 없음 → 라벨 PDF 단계에서 보강 예정
- 테스트 3개 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:29:54 +09:00
king f1097b85dc feat(dispatch): Shopee 플랫폼 추가 + 받는 사람 정보 저장/출고 엑셀
- 플랫폼 TikTok/Shopee 분리: 업로드 화면이 선택에 따라 파일 안내 변경
  (TikTok=Order Export xlsx, Shopee=Packing List xlsx / 라벨 PDF 선택)
- TikTok Picking List.pdf 업로드 제거(피킹 요약은 데이터 엑셀로 생성)
- 파서 공용화 + 헤더 행 자동탐지(Shopee 제목행 대응), 두 포맷 헤더 별칭 통합
- 받는 사람 이름/전화/주소를 박스 단위로 dispatch_db 에 저장(개인정보)
  · init SQL 갱신 + 기존 DB용 멱등 마이그레이션 dispatch_add_recipient_columns.sql
- 출고 작업 카드: Package 제거, 받는 사람 이름(크게)+주소(여러 줄)+전화 표시
- 배치 다운로드 zip 에 취합 출고 엑셀 포함
  파일명 YYYY.MM.DD(Ddd)_tictoc.xlsx / _shopee.xlsx (export.py)
- 문서(CLAUDE.md, DISPATCH_MODULE.md) PII 보관·Shopee 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:16:11 +09:00
king c0a661db82 feat(dispatch): 출고 배치별 업로드 원본 zip 다운로드
배치 목록에 '다운로드' 버튼 추가. 피킹/라벨 PDF + 주문 엑셀 중
실제 존재하는 파일만 zip 으로 묶어 내려준다. 한글 파일명은
RFC5987 filename* 으로 인코딩.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:50:53 +09:00
king 5426889f91 revert(dispatch): 송장 클립보드 복사 기능 제거
J&T 가 path 방식으로 자동 조회되어 클립보드 복사가 불필요해짐.
복사 JS·토스트·CSS·data-copy 속성 삭제. 송장 링크/스타일은 유지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:25:44 +09:00
king 8523a620ad fix(dispatch): J&T 송장 조회를 경로(path) 방식으로 자동 조회
jtexpress.my/tracking/<송장번호> 형태면 자동 조회됨(쿼리 파라미터는 무시).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:24:14 +09:00
king ec037d7045 style(dispatch): 송장 밑줄 강제표시·확대 + J&T 파라미터 제거
- 송장 링크 밑줄이 erp.css 전역 a 스타일에 덮여 안 보이던 것 !important 로 강제
- 송장번호 18px 로 확대
- J&T 는 파라미터 무시(자동입력 불가)라 깔끔한 공식 페이지만 열고 클립보드 복사로 처리

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:20:48 +09:00
king 6141f0e38a feat(dispatch): 송장 클릭 시 클립보드 복사 + J&T 공식 페이지로
- J&T 를 공식 조회 페이지(jtexpress.my/tracking)로 되돌림
- 송장번호 클릭 시 클립보드에 자동 복사 후 새 탭 열기(공식 페이지에 붙여넣기)
  복사 완료 토스트 표시. Ninja Van 등 자동입력 되는 곳도 복사는 동작.
- 송장 링크 굵기 800 + 밑줄 2px 로 강조

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:17:43 +09:00
king fe8a4f146e fix(dispatch): J&T 조회를 17TRACK 으로 + Order ID 라벨 확대
- J&T gzquery 엔드포인트 404 → 17TRACK(#nums 자동입력·조회)로 교체
  (J&T MY 공식 딥링크는 송장 자동입력 미지원). Ninja Van 은 공식 유지.
- 'Order ID' 라벨 14px 로 확대(숫자 크기는 유지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:13:37 +09:00
king 4a07ef139d feat(dispatch): 작업 2단계로 축소 + J&T 조회 URL 수정 + Order ID 확대
- 택배스캔 단계 제거 → 라벨부착 / Kagayaku 전달 2단계
- 버튼 라벨에서 '완료' 제거(라벨부착 / Kagayaku 전달)
- 완료 판정/배치목록 완료수를 label_attached AND handed_to_kagayaku 기준으로
- J&T 송장 조회 딥링크를 gzquery.html?bills= 로 교체(billcode 미입력 문제)
- Order ID 27px 로 확대, 송장 링크 밑줄 강조(굵기/오프셋)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:09:16 +09:00
king 1c14c748dd feat(dispatch): Order ID 강조 + 송장번호 배송조회 딥링크
- Order ID 를 카드에서 가장 크게(22px bold) 표시 — 가장 자주 보는 값
- Tracking 번호 클릭 시 배송사 조회 페이지로 송장번호 채워 새 탭 이동
  (store.courier_tracking_url + Jinja 필터 courier_track)
- Ninja Van/J&T 등 말레이시아 주요 배송사 URL 매핑(미매핑은 텍스트 폴백)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:02:44 +09:00
king bd79d20eb1 feat(dispatch): 작업 단계를 3개로 축소(라벨부착·Kagayaku전달·택배스캔)
상품은 미리 제작·박스 포장까지 끝나 들어오므로 상품준비/포장완료 단계 제거.
- STATUS_FIELDS 3개로 축소(DB 컬럼 product_ready/packed 는 보존)
- 카드 버튼·필터(미포장/포장완료 → 미완료) 갱신
- 완료 판정/카운트는 STATUS_FIELDS 기준이라 자동 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:58:06 +09:00
king ec8c1caced style(dispatch): 업로드 폼 파일 라벨을 번호 이모지 표기로 변경
표시 라벨만 변경(1️⃣/2️⃣/3️⃣). 서버 저장 파일명은 기존 안전한 이름 유지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 17:54:01 +09:00
king 31eaac8424 style(dispatch): 카드 상단 여백 제거 — 로고를 우상단 absolute 배치
큰 로고가 flex 헤더 줄 높이를 키워 No.1 위에 빈 띠가 생기던 문제 수정.
로고/텍스트 배지를 카드 우상단으로 absolute 처리해 줄 높이에 영향 없게 함.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:23:19 +09:00
king dfe79878b1 style(dispatch): 배송사 로고 표시 크기 2배(카드 44px·전달 40px)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:21:00 +09:00
king fd4ef9180c feat(dispatch): 배송사 로고 파일 확장자 자동 감지(png 우선)
디스크에 있는 <슬러그>.png 또는 .svg 를 탐지해 URL 반환(png 우선),
없으면 텍스트 폴백. 새 로고는 파일 1개 + 키워드 1줄로 추가 가능.
lru_cache 로 페이지당 반복 stat 최소화.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:08:45 +09:00
king a65ca65a8b feat(dispatch): 배송사 배지를 로고 이미지로 표시
- store.courier_logo_url: 배송사명(부분 일치) → 로고 SVG URL, 없으면 텍스트 폴백
- Jinja 필터 courier_logo 등록(main.py)
- Ninja Van / J&T Express 내장 SVG 로고 추가(static/dispatch/courier/)
- 출고 작업 카드 + Kagayaku 전달 요약에 로고 렌더

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:54:23 +09:00
king a189b93427 fix(dispatch): TikTok export 파싱 — read_only 버그 회피 + 설명행 스킵
- load_workbook read_only=True 제거: TikTok export 의 부실한 <dimension>
  메타 탓에 read_only 모드가 A열만 읽고 끊기는 openpyxl 버그(실제 54열 →
  1열만 반환)로 'Seller SKU 컬럼 없음' 오인 발생. 일반 모드로 전환.
- 헤더 바로 아래 '컬럼 설명' 행(2번째 행) 스킵: ID 필드에 공백이 있으면
  데이터가 아니라 설명 문장으로 판별(_is_description_row).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:27:29 +09:00
king 99eea01fc1 feat(dispatch): 말레이시아 TikTok 출고관리 모듈 추가
03_TikTok_Order_Export.xlsx 업로드 → 1박스=1카드 출고 작업 리스트,
SKU 피킹 요약, Kagayaku 전달표(A4 인쇄)를 자동 생성.

- 1박스 묶음 기준: Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산
- 작업 상태 5단계 토글(AJAX 즉시 저장) + dispatch_logs 기록
- 고객 이름/주소/전화 미저장(파싱 시 폐기, 스키마에 컬럼 없음)
- 엑셀은 openpyxl 파싱(pandas 미사용), DB는 dispatch_db 전용
- 인증은 기존 Google OAuth + 신규 권한키 dispatch 재사용
- scripts/sql/dispatch_db_init.sql, docs/DISPATCH_MODULE.md 동봉

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:11:43 +09:00
king 8453c425f8 feat(malaysia): POG 버튼명 변경 및 영문 모드 지원
'창고POG 출력' → 'POG 출력'. KO_EN 사전에 POG 출력/창고 POG
미리보기 추가해 ENG 모드서 영어 표시.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:32:23 +09:00
king a3a1d3fbad fix(malaysia): 창고 POG 인쇄 6열 강제 + 면당 한 장 보장
.rv-row 에 grid-template-columns:repeat(6,1fr) !important 로 6열
강제(인쇄 시 3열로 덮이던 문제 차단). height:210mm 고정 제거 →
height:auto + 칸 min-height:50mm 로 프린터 기본여백 더해져도 면이
한 장 넘던 문제 해소. rv-side flex 해제해 자연 높이로 흐름.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:29:23 +09:00
king 1424772afa fix(malaysia): 창고 POG 인쇄 시 grid 깨짐 수정(6열 복구)
container-type:size 가 인쇄에서 자식 grid 를 3열로 깨는 Chrome
버그 회피 — 인쇄 전용으로 containment 해제, 글자·여백 mm 고정,
시트 height:210mm(A4 가로 1면=1장). 화면 미리보기는 cqh 유지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:24:21 +09:00
king b0925b86a1 fix(malaysia): 창고 POG 글자 축소로 면별 한 페이지에 맞춤
cqh 글자 2.6→1.55, 패딩·간격 축소. 셀 내용 잘림·페이지 초과 해소.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:20:04 +09:00
king fb114a222c feat(malaysia): 창고 POG를 면별 A4 1장(Side A/B/Ground)으로 분리
전체 1장 → 면마다 한 페이지로 출력. 행을 flex 균등 분배해
칸·글자 크게(cqh 2배) 한눈에 보이게. 시트별 page-break 적용.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:16:15 +09:00
king 0ce932f0e2 feat(malaysia): 창고 랙 전체 도면 A4 1장 'POG 출력' 추가
창고POG 출력 버튼 → Side A/B/Ground 전체 도면을 A4 가로 1장에
미리보기 후 인쇄. container-type:size + cqh/cqw 로 시트 높이에
맞춰 자동 축소. 기존 칸별 인쇄와 body 클래스(printing-cells/
printing-pog)로 분리해 인쇄 대상 충돌 방지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:11:14 +09:00
king 650a14fe7c style(malaysia): 창고 랙 보기 화면 글자 크기 확대
칸코드/상품명/계산식 11→14px, 셀 패딩·높이 확대해 가독성 개선.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:38:02 +09:00
king ddb06e42db feat(malaysia): 콤보 세트 MY-0006(MALAYSIA Special Set) 등록
malaysia_items 시드에 MY-0006 추가, 운영 DB용 멱등 마이그레이션 추가.
BOM 구성은 itemcode_db.set_components 에서 실시간 조회 — 코드 변경 불필요.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:29:10 +09:00
king aedb5f1fdb fix(malaysia): 랙 엑셀 합계 제목행 배경색 위치 어긋남 수정
append([]) 은 셀이 없어 max_row 가 증가하지 않아, 합계 제목행 대신
바로 위 빈 줄에 배경색이 칠해졌다. 빈 문자열 셀로 한 줄 띄우고
제목행 추가 직후 max_row 로 정확한 행을 잡아 스타일 적용.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:44:23 +09:00
king 2bbe36ccf0 feat(malaysia): 랙 엑셀에 아이템별 합계 표 추가
상세 표 아래(한 줄 띄움)에 아이템 코드별 수량 합계 표를 추가.
컬럼: 아이템 코드/이름/합계 수량, 코드순 정렬. 자동필터는 상세
표 범위에만 적용(합계 표 제외). 열너비 자동은 그대로.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:42:05 +09:00
king ec9630144d feat(malaysia): 창고 랙 보기 엑셀 다운로드 추가
랙 보기 기준(latest_rack_stocktake)의 모든 칸 항목을 xlsx 로 내보내는
/rack/export 엔드포인트와 다운로드 버튼 추가. 컬럼: 아이템 코드/이름/
수량/랙번호. 표 전체 자동필터, 각 열 내용 길이에 맞춰 너비 자동(한글
폭 보정). 뚜껑(MD-) 이름도 병합해 표시.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:36:54 +09:00
king 5c43cb801c fix(malaysia): 랙 sku_code CHECK 제약에 뚜껑(MD-) 허용
뚜껑 저장 시 daily_stocktake_rack.sku_code CHECK 제약(MT/MX/MZ/MY만)
위반으로 Internal Server Error 발생. init SQL 의 CHECK 에 'MD-%' 추가,
운영 DB용 멱등 마이그레이션(malaysia_stock_db_rack_allow_lid.sql) 제공.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:07:22 +09:00
king 55a2fc8793 feat(malaysia): 뚜껑(MD-) 랙 위치확인 배치 허용, 재고는 미반영
뚜껑(MD-0000~0005)을 재고조사 랙 입력 드롭다운에 노출해 창고 랙
어디에 있는지 위치만 기록할 수 있게 함. 재고에는 반영하지 않음.

- store.LID_ITEMS / lid_name_map 추가
- validate_rack_code 신설(개별/세트/뚜껑 허용) → replace_rack·
  reposition_rack 검증에 적용. 직접 라인 입력(validate_stocktake_code)은
  여전히 MD- 차단해 재고 오염 방지.
- aggregate_rack 에서 MD- 집계 제외 → daily_stocktake_line 미생성.
- 랙 드롭다운 목록에 LID_ITEMS 추가, 랙 보기 이름맵에 뚜껑 이름 병합.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:40:17 +09:00
king fa396bd237 feat(malaysia): 재고조사 랙에 Side B 아래 Ground(G1~G6) 6칸 추가
Side B 다음에 Ground 영역을 추가하고 단일 파렛트 6칸(G1~G6)을
한 줄로 배치. rack_cell_codes 검증 목록과 rack_layout 렌더 구조에
반영. 면 제목을 side.label 로 바꿔 A/B는 'Side A/B', Ground는
'Ground'로 표기. 입력/이동 저장 검증 모두 G1~G6 허용.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:23:40 +09:00
king a683c38b76 fix(malaysia): 랙 인쇄 시 앱 셸 100vh 클립 풀어 모든 파렛트 출력
앱 셸(body.erp-app-body/.erp-app/.erp-content height:100vh +
overflow:hidden, .erp-page overflow:auto)이 한 화면만 보이게 잘라,
인쇄 시 둘째 칸부터 잘려 항상 1장만 인쇄되던 근본 원인. 인쇄
미디어에서 조상 요소의 height/overflow 제한을 해제해 선택한 모든
파렛트가 각 장으로 출력되게 수정.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:16:06 +09:00
king 6a2eded9b5 fix(malaysia): 랙 인쇄 페이지 분리를 break-before 로 변경
break-after:page 가 Chrome에서 100vh 시트에 무시돼 여러 파렛트가
한 장에 인쇄되던 문제. 첫 칸 제외 매 칸에 break-before:page 적용해
파렛트마다 새 장으로 확실히 분리.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:12:20 +09:00
king fb473f74ee fix(malaysia): 랙 다중 파렛트 인쇄 시 파렛트당 한 장씩 분리
미리보기 컨테이너(.rvp-pages)가 flex라 Chrome이 자식의 page-break를
무시해 여러 파렛트가 한 장에 뭉쳐 잘려 인쇄됐다. 인쇄 시 block 으로
바꾸고 break-after:page 적용해 파렛트(칸)마다 종이 한 장씩 출력.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:06:56 +09:00
king a807238cba fix(malaysia): 랙 가로 인쇄 상하 여백 0 처리해 세로 공간 확보
가로 인쇄 시 상하 여백은 프린터 좌우에 해당. 0으로 줄여 세로
출력 공간을 최대화. 좌우는 4mm 유지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:01:46 +09:00
king cb3b6841e1 fix(malaysia): 랙 인쇄 시 상품 2개 이상이면 글자 용지 넘침 수정
미리보기는 vmin 비율로 자동 축소돼 한 장에 맞지만 실제 인쇄는
고정 cm 크기를 상품 1개 기준으로 사용해 2개 이상이면 세로로
용지를 넘어가 글자가 잘렸다. 칸 상품 수에 따라 인쇄 글자 크기를
단계(rvp-multi/rvp-many)로 축소해 A4 가로 1장에 맞게 수정.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:58:49 +09:00
king 81352d1ca7 fix(malaysia): 랙 인쇄 시 빈 페이지 출력 수정
비인쇄 요소를 visibility:hidden 대신 display:none 으로 제거. 기존엔 숨긴 요소가 공간을 차지해 인쇄 미리보기 overlay(static)가 아래로 밀려 첫 페이지가 비었음.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:37:48 +09:00
king d2701c2e22 style(malaysia): 콤보 재고 영역 확대·Last Stocktake 열 축소
콤보 카드 420→520px, Last Stocktake 96px 고정, 콤보 숫자열 nowrap·52px → 가로 스크롤 제거.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:52:58 +09:00
king 411c03e352 fix(malaysia): 콤보 재고 패널 영문 번역 추가
헤더(조사/출고/재고) data-ko/data-en, 안내문 i18n(날짜 분리), malaysia.js 캐시버전 갱신. 짧은 단어는 사전 대신 data 속성 사용해 RICH 부분치환 오염 방지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:49:48 +09:00
king 5b9d9c45ea fix(malaysia): 같은 날 콤보 출고도 콤보 재고에 반영
콤보 재고 차감 기준을 movement_date > stocktake_date 에서
재고조사 created_at < set_movement.created_at 으로 변경.
날짜만 비교하면 재고조사 당일 출고가 누락되던 문제 수정.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:40:30 +09:00
king c786fbabd7 feat(malaysia): 콤보 출고를 콤보 재고현황에 반영
- set_movement(콤보 단위 출고 원장) 테이블 신설 + 마이그레이션 SQL
- create_set_out: 낱개 OUT 과 함께 콤보 OUT 1행 기록(같은 트랜잭션)
- 콤보 재고 = 최근 재고조사 콤보수량 − 재고조사일 이후 콤보 출고
- 재고현황 콤보 패널: 조사/출고/재고 3열 표시
- reset_all_data 에 set_movement 삭제 포함

배포: malaysia_stock_db_set_movement.sql 적용(테이블+GRANT) 후 코드 배포.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:35:40 +09:00
king 765d3ba985 feat(malaysia): BOM 없는 세트 있어도 재고조사 확정 허용(경고만)
- finalize_stocktake 의 missing_bom 하드 차단 제거 → 확정/저장 진행
- BOM 없는 세트는 낱개로 분해되지 않고 0 반영, 경고 로그만 남김
- 세트 BOM 분리는 itemcode_db(set_components) 권한이 정상일 때 자동 적용

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:12:13 +09:00
king 03a96b6118 feat(malaysia): 랙 저장 버튼 수정 시 빨강 강조·저장 안내창
- 드래그로 칸 재배치하면 저장 버튼을 검정→빨강(흰 글씨)으로 강조
- 저장 클릭 시 저장되었습니다. 안내창 표시 후 제출

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:13:02 +09:00
king f383297fc5 fix(malaysia): 인쇄 미리보기에서 빈 랙 제외
선택한 칸 중 비어 있는 칸은 미리보기/인쇄에서 제외. 선택한 칸이 모두
비어 있으면 '인쇄할 문서가 없습니다' 안내 후 미리보기를 열지 않음.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:31:11 +09:00
king e256c6c5ee style(malaysia): 랙 인쇄 레이아웃 — 번호 제거·용량 강조·박스 확대
- 인쇄/미리보기에서 랙 번호 제거(불필요)
- 여백 축소(@page 4mm, 시트 2mm), 글자 전반 확대
- 상품명과 용량(750ml 등) 줄 분리, 용량을 최대(5.5cm)로 키워 원거리 가독
- 계산식의 박스 수(21box)를 굵게 3cm로 강조

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:12:56 +09:00
king 6763b2af68 feat(malaysia): 랙 보기 드래그 재배치·저장·인쇄 미리보기
랙 보기에서 칸을 드래그해 다른 칸으로 구성을 옮긴다(채워진 칸끼리 맞교환,
Side A↔B 가능). 칸 번호는 유지되고 수량 합계도 불변(위치만). 저장하면 그
배치로 기록되며, 재고조사 '이전값 불러오기'가 이 최종 위치를 불러온다.

- db.reposition_rack: 칸(cell_code) 배치만 교체, daily_stocktake_line 보존,
  확정본도 허용(취소만 불가). 위치 이동은 전산재고에 영향 없음
- db.previous_rack_entries: 정렬을 랙 저장시각(MAX created_at) 기준으로 통일 →
  '이전값 불러오기'가 랙 보기 최종 위치와 일치
- POST /malaysia/rack/save: latest_rack_stocktake 대상 reposition
- rack_view.html + malaysia_rack_view.js: HTML5 드래그 이동/swap, 칸 체크 선택,
  저장(병렬배열 직렬화), 인쇄 미리보기 모달
- 인쇄: 선택한 칸을 칸마다 A4 가로 1장, 굵은 대형 글씨(@media print),
  미리보기 후 인쇄
- i18n 항목 추가, malaysia.js 캐시버스트 v20260615c

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:06:01 +09:00
king 2c0eeb5502 feat(malaysia): 확정된 날짜에 재고조사 생성 시 안내창 차단
같은 날짜+창고에 finalized 조사가 있으면 생성 버튼 클릭 시 alert 로
'{날짜} 은 재고조사가 확정되어 다시 재고조사를 생성할 수 없습니다.' 안내
후 제출 차단. 확정 날짜는 이미 받아온 stocktakes 목록에서 추출(추가 쿼리
없음). 서버측 차단(create_stocktake ValueError)은 안전망으로 유지. 한/영 안내.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:38:55 +09:00
king 6e32c68590 feat(malaysia): 재고조사에 '이전값 불러오기' 버튼 추가
작성중 재고조사 상세에서 직전 조사의 랙 저장값을 한 번에 불러와 채운다.
사용자는 이어서 수정 후 저장/확정. 직전 조사에 랙 입력이 있을 때만 버튼 노출.

- db.previous_rack_entries: 현재 제외·취소 제외, 랙 있는 가장 최근 조사의
  랙 항목 반환(replace_rack 입력 형식과 호환)
- POST /stocktakes/{id}/rack/load-previous: 이전 랙을 현재 조사로 replace_rack
  적용 후 리다이렉트(화면 복원). 작성중만 허용, 이전값 없으면 400
- 상세 핸들러에 has_prev_rack 플래그, 템플릿에 formaction 버튼(덮어쓰기 confirm)
- i18n '이전값 불러오기'→'Load previous', JS 캐시버스트 v20260615b

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:29:54 +09:00
king 4d05c7fad5 fix(malaysia): 랙보기 상단 제목 영문 미번역 보완
ENG 토글 시 topbar 제목 '창고 랙'(page_title)·'랙 보기'(page_subtitle)가
KO_EN 사전에 없어 '랙' 글자만 한글로 남았다. 두 항목 추가로 전체 번역.
malaysia.js 캐시버스트 v20260615a.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:22:48 +09:00
king c91059b415 fix(malaysia): 랙보기를 '최근 저장' 재고조사 기준으로
기존 랙보기는 latest_stocktake(조사 날짜 DESC) 기준이라, 방금 저장한
조사라도 날짜가 더 늦은 다른 조사가 있으면 안 보였다. 랙을 마지막으로
저장한 조사를 보여주도록 latest_rack_stocktake 추가 — daily_stocktake_rack
.created_at MAX 기준(replace_rack 이 매 저장마다 delete+insert 하므로
랙 저장 시각과 일치). 재고현황 index 의 날짜 기준 latest_stocktake 는 유지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:49:30 +09:00
king b970542482 fix(shell): 모바일에서 grid 해제로 본문 풀폭 강제
특이도 조정만으론 부족. 모바일(≤900px)에서 .erp-app grid를 아예
display:block 으로 해제해 데스크탑 collapsed :has 규칙과의 칸 분배
충돌을 원천 제거. 사이드바는 fixed 오버레이(z-index 1000), 본문은
width:100%. body 높이/overflow도 모바일에선 auto로 풀어 콘텐츠 잘림
방지. CSS 캐시버스트 v20260615b.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:40:31 +09:00
king 438f309178 fix(shell): 모바일에서 사이드바 칸이 본문을 가리는 문제
데스크탑 접힘 규칙 .erp-app:has([data-collapsed=true])(특이도 높음)가
모바일 미디어 .erp-app{...0 1fr}(특이도 낮음)를 이겨, 폰에서도
사이드바가 64px grid 칸을 차지해 본문이 좁게 짜부러짐. 미디어쿼리는
특이도를 올리지 않음. 모바일 미디어에 :has 셀렉터를 함께 명시해
특이도를 맞추고 사이드바 칸을 0으로 강제. CSS 캐시버스트 갱신.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:24:58 +09:00
king eb6ee77f72 style(malaysia): 모바일 화면 최적화 — 다단 그리드·랙·표 반응형
- _i18n.html 공용 모바일 CSS: ≤820px 다단 그리드 1단 적층,
  정보바 줄바꿈, 표 가로 스크롤로 열 정렬 유지, 랙 6→3열
- ≤480px 랙 3→2열, 코드열 폭 자동
- index·movements·stocktakes 고정폭 그리드에 mys-grid 클래스

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:06:10 +09:00
king f4133ad61d style(malaysia): 재고현황·최종계산 숫자 천단위 콤마
- 재고현황: 전산재고/조사수량/차이(부호+콤마)/콤보 Qty
- 재고조사 상세 최종계산: direct/from_set/total

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:41:31 +09:00
king 9377cd2f5a style(malaysia): 랙 보기 표기 순서 — 입수량개 × 박스수박스
'20박스 × 150' → '150개 × 20박스 = 3,000' 으로 직관적 표기.
입수량에도 천단위 콤마, i18n '개' 키 추가.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:39:36 +09:00
king 536e5a35f1 fix(malaysia): 확정 시 랙 입력 누락 방지 — 저장+확정 통합
확정 버튼이 랙 폼과 별개라, 입력 후 저장 없이 확정하면 랙/라인 0건으로
빈 확정이 되던 문제 수정.

- 확정 버튼을 랙 폼 안으로 이동(formaction=/rack/finalize)
- 신규 POST /stocktakes/{id}/rack/finalize: replace_rack 후 finalize
- _parse_rack_form 헬퍼 추출(저장/확정 공용)
- 입력 저장은 outline, 확정은 primary 로 구분

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:36:11 +09:00
king eacde5b322 style(malaysia): 입수량·박스 한 줄 + 칸 합계 제거
- 입수량/박스 입력을 한 줄로(.rk-line)
- 칸 ∑ 합계 제거 — 서로 다른 상품을 합산해 의미 없음(입력·랙보기 양쪽)
- 상품별 행 소계(합계)는 유지(개별 SKU 기준 유효)
- malaysia_rack.js: 칸 합계 로직 삭제, 행 소계만 갱신

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:10:49 +09:00
king b24a418718 style(malaysia): 랙 합계·∑ 천단위 콤마
- malaysia_rack.js: 행 소계/칸 합계 toLocaleString, 로드 시 기존 행도 recalc
- stocktake_detail/rack_view: 서버 렌더 합계도 {:,} 포맷

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:04:25 +09:00
king f1eac5eda2 style(malaysia): 랙 입력란 세로 배치 + 레이블 노출
좁은 칸에서 placeholder 가 잘려 안 보이던 문제 해결.
- 각 입력행을 세로 스택(상품 select → 입수량 → 박스 → 합계/삭제)
- '입수량'·'박스' 레이블 항상 표시
- 중복 .rk-del CSS 정리, i18n 키 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:01:40 +09:00
king 1e0e69fcbe feat(malaysia): 창고 랙 보기 페이지
최근(취소 제외) 재고조사의 랙 입력을 칸별로 한눈에 보는 읽기전용 메뉴.
각 칸에 상품명·박스수×입수량=총개수, 칸 합계(∑) 표시.

- nav: '창고 랙 보기' 탭 추가(active_tab='rack')
- router: GET /malaysia/rack — latest_stocktake → list_rack_entries 칸별 그룹
- 템플릿 rack_view.html — Side A/B 세로 적층, 칸별 아이템 목록
- i18n 키 추가(Rack View 등)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:48:07 +09:00
king 4c7eb9467f style(malaysia): 랙 보드 세로 적층 + 상단 정보바 한 줄
- Side A/B 를 세로로 적층(각 면 풀폭) → 칸·입력란 넓게, 한 화면 활용
- 상단 정보(날짜/창고/상태/안내/저장)를 mys-info-bar 로 한 줄 고정
  (erp-card 중앙정렬에 먹혀 세로로 쌓이던 문제 해결)
- 칸 넓어진 만큼 입력란 폭·폰트 확대

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:41:41 +09:00
king d92da186f3 style(malaysia): 랙 입력 UI — 정보 상단 이동·풀폭·코드 숨김
- 날짜/창고/상태/안내를 상단 가로 스트립으로 이동(좌측 280px 칸 제거)
- 랙 보드 풀폭 → A/B 두 면 한 화면에 나란히
- 드롭다운에 상품코드 숨기고 이름만 표시(value 는 코드 유지)
- 입력란 폭 축소·min-width:0 으로 칸 넘침 방지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:37:37 +09:00
king 032fb78867 feat(malaysia): 재고조사 랙(rack) 박스 단위 입력
낱개/콤보 평면 수량 그리드를 창고 랙 그림(A/B 36칸) 입력으로 대체.
각 칸에 아이템·박스당 입수량·박스 수를 넣고 + 로 한 칸 여러 아이템 추가.
저장 시 SKU별 qty=SUM(입수량×박스수) 집계로 daily_stocktake_line 재생성.
이후 분해/확정 로직은 라인 기준 그대로 동작.

- DB: daily_stocktake_rack 테이블 + grant (멱등)
- store: rack_cell_codes/rack_layout/aggregate_rack 순수함수
- db: list_rack_entries/replace_rack(1 트랜잭션, draft만)
- router: rack 컨텍스트 + POST /stocktakes/{id}/rack/bulk
- UI: 랙 보드 템플릿 + malaysia_rack.js(행 추가/삭제·실시간 소계)
- 테스트 4건 추가(18 전부 통과)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:25:40 +09:00
king 65997adfd3 i18n(malaysia): 콤보 입고 차단 안내 영문 모드 대응
영문 토글 시 콤보 입고 불가 alert도 영어로 표시 (mys_lang 기준 분기)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 12:47:41 +09:00
king 8f882ff4cd feat(malaysia): 슈퍼관리자 재고 데이터 초기화
- 재고 현황 하단 '위험 구역' 카드(슈퍼관리자만 표시)
- POST /malaysia/reset: stock_movement + daily_stocktake(+line) 전체 삭제
  → 전체 창고 현재고/조사값 0. 마스터(창고/아이템) 보존.
- 권한: is_super_admin 아니면 403 (버튼 숨김 + 서버 이중 검사)
- 2단 확인(confirm + RESET 타이핑)
- i18n 추가, malaysia.js 캐시 버전 bump

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:32:30 +09:00
king 84d266ea89 style(malaysia): 입출고 엑셀 헤더 서식 + 열너비 자동
- A1~C1 파랑 배경/흰색 굵은글씨/가운데 정렬
- A~C 열너비 내용 길이 자동(한글 폭 2배 보정, 8~50 clamp)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:12:31 +09:00
king 56cff672e2 fix(malaysia): 콤보 입고 차단 — 낱개 분리 입고 안내
- 콤보(MY-)는 입고 불가 구조. 입고 선택 + 콤보 수량 입력 시 차단.
- 클라이언트: 제출 전 alert 안내(낱개로 분리 입고)
- 서버: 동일 조건 400 (JS 우회 대비)
- 세트 movement 는 항상 OUT 유지(create_set_out 원복)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:59:09 +09:00
king d56365b338 feat(malaysia): 입출고 일자별 낱개 수량 엑셀 다운로드
- '일괄 적용' 버튼명 '입력'으로 변경
- 입력 버튼 우측 '다운로드' 버튼 추가
- GET /malaysia/movements/export: 해당 일자/종류(IN/OUT) 낱개 합계를
  엑셀(A=사방넷코드, B=수량, C=이름)로 스트리밍
- db.daily_movement_totals(): 일자/종류별 낱개 합계 집계
- itemcode.sabangnet_code_map(): item_code → 사방넷 코드 매핑
- i18n 사전 '입력'/'다운로드' 추가, malaysia.js 캐시 버전 bump

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:36:10 +09:00
king 03929ff517 feat(malaysia): 메뉴명 '말레이시아 재고관리'로 변경 + 사이드바/헤더 영문 토글
- 메인 메뉴/라벨 '말레이시아 재고' → '말레이시아 재고관리'
- EN 토글 시 사이드바 활성 항목(말레이시아만)·상단 페이지 제목/부제 부분치환 번역
  (다른 메뉴는 그대로)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:49:46 +09:00
king b18efcd814 ui(malaysia): 입출고 옵션 type/date/memo 입력폭 통일(100%)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:41:31 +09:00
king 3139e213a7 feat(malaysia): 전 화면 한글/영문 실시간 토글
- static/malaysia.js: KO_EN 사전 + 토글(localStorage 기억, 새로고침 없이 즉시)
- _i18n.html 공용 include(ENG/한글 버튼 + 스크립트), 전 화면 상단 삽입
- UI 고정 문구는 사전 치환(제목/헤더/버튼/라벨/배지/옵션/안내)
- 상품/콤보명은 data-en(영문, malaysia_items)·data-ko(itemcode_db 한글) 동시 렌더
- _base_ctx 에 ko_names(itemcode_db name_map) 주입

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:14:42 +09:00
king b312884ddc ui(malaysia): 비관리자에게 확정 안내문도 숨김
확정 버튼은 관리자만, 일반 사용자는 안내문 없이 취소만 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:16:09 +09:00
king 6f4a62013a feat(malaysia): 콤보 상품 MY-9991 Combo 2.5L+6L seed 추가
malaysia_items 콤보 목록에 MY-9991 추가(BOM은 itemcode_db set_components 참조)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:11:29 +09:00
king c07d3301e4 feat(malaysia): 재고현황 조사수량·차이 표기, 확정 관리자 제한, 슈퍼관리자 조사삭제
- 재고현황 헤더 "전산 재고 현황", Last Stocktake 옆 조사수량+차이(±) 표기
  (차이 = 마지막 재고조사 낱개 total − 전산재고)
- 재고조사 확정(전산 반영)은 관리자(is_admin)만 가능, 비관리자 안내
- 슈퍼관리자만 재고조사 완전 삭제(목록/상세 버튼 + /delete 라우트)
- "◀◀ 조사 목록" 버튼 검정 바탕(primary)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:38:38 +09:00
king 3d85ccf43e ui(malaysia): Code 컬럼 nowrap+고정폭, Qty 폭 축소(110px)
입출고/재고조사/재고현황 표의 Code(Set Code) 줄바꿈 방지, Qty 입력폭 축소

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:23:28 +09:00
king ea03a7aa18 ui(malaysia): Qty 150px·name 한줄, 재고조사 3분할, 재고현황 낱개/콤보 2분할
- Qty 컬럼 150px 고정, 상품명 줄바꿈 방지(nowrap)
- 콤보 Qty(OUT) 헤더 → Qty
- 일일 재고조사 상세 3분할(좌 정보/동작·중 낱개·우 콤보)
- 재고현황: 좌 낱개 분해 전체 + 우 콤보(최근 재고조사 세트 수량)
- db.latest_set_quantities 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:17:26 +09:00
king 9039d1d80b ui(malaysia): 이동이력 날짜필터+요일, Ref열 제거, 헤딩 변경(낱개/콤보 상품)
- 이동 이력에 날짜 선택 필터(요일 포함 표시), 날짜+종류 조합 유지
- 이력 Ref 컬럼 삭제
- "낱개 아이템"→"낱개 상품", "세트 아이템 — 출고(OUT)"→"콤보 상품"
- 안내 문구 삭제

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:05:50 +09:00
king 300f4e23c8 ui(malaysia): 입출고 3분할 레이아웃 — 좌(옵션)·중(낱개)·우(세트)
종류/날짜/메모+적용버튼을 좌측 카드로, 낱개·세트 그리드를 중/우로 배치

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 15:58:53 +09:00
king 4fdb5c181e ui(malaysia): 입출고 화면 정리 — Ref Type/No·안내문 제거, 행 간격 압축
- 불필요 필드(Ref Type, Ref No)와 안내 문구 삭제
- 테이블/입력 행 padding 축소로 낱개+세트 한 화면에 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 15:56:05 +09:00
king 3b3f9739fe feat(malaysia): 입출고 화면 통합 — 낱개(좌)·세트(우) 한 폼, 버튼 하나로 일괄 적용
- /movements/bulk + /movements/set-out-bulk → /movements/apply 단일 엔드포인트
- 낱개는 선택 종류(IN/OUT/ADJUST), 세트는 항상 OUT(BOM 분해)
- 2컬럼 그리드 한 폼, "일괄 적용" 버튼 하나

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 15:50:42 +09:00
king 6b43d9926d feat(malaysia): 세트 BOM을 itemcode_db에서 읽고 입력을 그리드 일괄로 전환
- 세트구성(set_bom) 수동 관리 메뉴/라우트/API/템플릿 제거
- 세트 BOM은 itemcode_db set_components(set_code/single_code/quantity)에서
  읽음 (MalaysiaItemcodeReader, ITEMCODE_DB_URL 재사용)
- db.py: create_set_out/compute_result/finalize_stocktake 에 bom_map 주입식
- 입출고: 낱개 전체 그리드 일괄 등록 + 세트 출고 그리드(BOM 분해)
- 일일 재고조사: 낱개+세트 전체 그리드에 수량만 키인 → 일괄 저장
- malaysia_stock_db.set_bom 테이블은 레거시(미사용)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 15:35:42 +09:00
king f137f2a6a6 feat(malaysia): 말레이시아 창고 재고관리 모듈 추가
- 신규 모듈 app/modules/malaysia (prefix /malaysia, 권한키 malaysia)
- malaysia_stock_db: warehouses/malaysia_items/set_bom/stock_movement/
  daily_stocktake/daily_stocktake_line (멱등 init SQL)
- 낱개(MT/MX/MZ) 입출고·조정 movement, 세트(MY) BOM 관리, 세트 출고 BOM 분해
- 일일 재고조사: 세트→낱개 자동 분해(direct+from_set=total), 확정 시 차이만 STOCKTAKE
- prefix 검증 이중화(DB CHECK + store.py 순수함수), MD- 전면 제외
- 상품명은 itemcode_db 읽기 전용 재사용(중복 마스터 없음)
- 화면 4종 + JSON API, 순수로직 테스트 14건, README/docs 갱신

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:34:31 +09:00
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
124 changed files with 22187 additions and 261 deletions
+52
View File
@@ -20,3 +20,55 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
# 설정하면 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
# ─── 말레이시아 창고 재고관리 모듈 (malaysia_stock_db) ───
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
# DB/역할/스키마/창고·아이템 seed 생성: scripts/sql/malaysia_stock_db_init.sql 참고.
# 권한키: malaysia(접근). admin 은 항상 통과. 상품명은 아래 ITEMCODE_DB_URL 재사용.
# MALAYSIA_STOCK_DB_URL=postgresql://malaysia_app:replace-me@postgres-db:5432/malaysia_stock_db
# ─── 말레이시아 배송 모듈 (dispatch_db) — TikTok 출고관리 ───
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
# DB/역할/스키마 생성: scripts/sql/dispatch_db_init.sql 참고.
# 권한키: dispatch(접근). admin 은 항상 통과.
# 업로드 원본 파일은 DATA_DIR/dispatch/<날짜>/<배치>/ 아래에 저장된다.
# 개인정보(고객 이름/주소/전화)는 저장하지 않는다.
# DISPATCH_DB_URL=postgresql://dispatch_app:replace-me@postgres-db:5432/dispatch_db
# ─── 프로젝트 관리 모듈 (project_db) — 아사나식 ───
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
# DB/역할/스키마 생성: scripts/sql/project_db_init.sql 참고.
# 진입 권한: 로그인한 회사 직원 전원(별도 권한키 없음). 프로젝트 생성/배정은 관리자만.
# PROJECT_DB_URL=postgresql://project_app:replace-me@postgres-db:5432/project_db
# ─── 메일 알림 (SMTP) — 프로젝트 업무 배정/완료 시 관리자 알림 ───
# SMTP_HOST 가 있어야 발송. 미설정 시 조용히 skip(앱은 정상 동작).
# SMTP_TLS: true(STARTTLS,587 기본) | ssl(SMTPS,465) | false(평문)
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_USER=alarm@dbxcorp.co.kr
# SMTP_PASSWORD=app-password-here
# SMTP_FROM=alarm@dbxcorp.co.kr
# SMTP_TLS=true
# 알림 수신자(쉼표구분). 비워두면 ERP 관리자(admin) 전원에게 발송.
# PROJECT_NOTIFY_EMAIL=king@dbxcorp.co.kr
# ─── 상품 검색 (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
+6
View File
@@ -2,6 +2,9 @@
.env
.env.local
# 런타임 데이터 (사용자 이메일/경비 등 PII — 커밋 금지. 운영은 DATA_DIR 볼륨)
app/data/
# 로컬 전용 스크립트 (자격증명 포함 가능)
push-gitea.bat
run-local.bat
@@ -17,3 +20,6 @@ __pycache__/
.idea/
.claude/
# dispatch 라벨 PDF 샘플(실제 고객 PII — 커밋 금지)
docs/samples/
+6
View File
@@ -30,6 +30,12 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
- CS관리
- 반품관리
- 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등)
- 개인경비 (`app/modules/expense/`, `expense_db`)
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia`
- 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1박스=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1박스 묶음 기준 Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 박스 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md`
- 프로젝트 관리 (`app/modules/project/`, `project_db`) — 아사나식. 프로젝트/서브프로젝트(self-FK `parent_id`, CASCADE)·업무(`tasks`: 담당자·우선순위·시작/마감)·진행단계(`project_stages` 칸반, 생성시 기본 4단계 seed)·멤버 배정(`project_members`)·활동이력(`project_activity`). 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 버튼 토글 + 보드(드래그로 단계 이동)/리스트. 진입 권한키 `project`(관리자 페이지 토글로 직원별 부여, admin 자동). 프로젝트 생성/삭제·사용자 배정은 `is_admin` 만, 배정 멤버(또는 owner)는 서브프로젝트/업무/단계 CRUD. 멤버 배정 후보는 `project` 권한 보유 등록 사용자에서 자동 목록(`GET /project/api/assignable-users`). 업무 배정·완료 시 관리자에게 메일(`app/mail.py` stdlib smtplib, `SMTP_*`+`PROJECT_NOTIFY_EMAIL` env, 미설정 시 조용히 skip, `BackgroundTasks` 비동기). 상세는 `docs/PROJECT_MODULE.md`
상세는 `docs/PROJECT_OVERVIEW.md`.
+97
View File
@@ -0,0 +1,97 @@
"""SMTP 메일 발송 유틸 (표준 라이브러리 smtplib 만 사용 — 추가 의존성 없음).
모든 모듈이 공용으로 쓰는 가벼운 발송기. 현재는 프로젝트 관리 모듈의
관리자 알림(업무 배정/완료)에 사용한다.
환경변수:
SMTP_HOST SMTP 서버 호스트 (미설정 시 발송 비활성 — 조용히 skip)
SMTP_PORT 포트 (기본 587)
SMTP_USER 로그인 사용자 (없으면 익명)
SMTP_PASSWORD 로그인 비밀번호
SMTP_FROM 보내는 사람 (미설정 시 SMTP_USER)
SMTP_TLS "true"(STARTTLS, 기본) | "ssl"(SMTPS/465) | "false"(평문)
SMTP_TIMEOUT 연결 타임아웃 초 (기본 10)
설계 원칙:
- 미설정/실패해도 앱이 죽지 않는다. 로그만 남기고 False 반환.
- 호출부는 FastAPI BackgroundTasks 로 비동기 호출 권장(요청 지연 방지).
"""
from __future__ import annotations
import logging
import os
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
logger = logging.getLogger("app.mail")
def _env(name: str, default: str = "") -> str:
return (os.getenv(name, "") or "").strip() or default
def mail_enabled() -> bool:
"""SMTP_HOST 가 있으면 발송 가능으로 본다."""
return bool(_env("SMTP_HOST"))
def send_email(
*,
to: list[str] | str,
subject: str,
body: str,
html: str | None = None,
from_name: str = "DBX ERP",
) -> bool:
"""단순 텍스트(+선택 HTML) 메일 1건 발송.
반환: 성공 True / 비활성·실패 False. 예외를 밖으로 던지지 않는다.
"""
host = _env("SMTP_HOST")
if not host:
logger.info("SMTP 미설정 — 메일 발송 skip (subject=%s)", subject)
return False
recipients = [to] if isinstance(to, str) else list(to)
recipients = [r.strip() for r in recipients if r and r.strip()]
if not recipients:
logger.info("수신자 없음 — 메일 발송 skip (subject=%s)", subject)
return False
port = int(_env("SMTP_PORT", "587"))
user = _env("SMTP_USER")
password = _env("SMTP_PASSWORD")
sender = _env("SMTP_FROM", user or "no-reply@dbxcorp.co.kr")
mode = _env("SMTP_TLS", "true").lower()
timeout = int(_env("SMTP_TIMEOUT", "10"))
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = formataddr((from_name, sender))
msg["To"] = ", ".join(recipients)
msg.set_content(body)
if html:
msg.add_alternative(html, subtype="html")
try:
if mode == "ssl":
with smtplib.SMTP_SSL(host, port, timeout=timeout) as server:
if user:
server.login(user, password)
server.send_message(msg)
else:
with smtplib.SMTP(host, port, timeout=timeout) as server:
server.ehlo()
if mode != "false":
server.starttls()
server.ehlo()
if user:
server.login(user, password)
server.send_message(msg)
logger.info("메일 발송 완료 → %s (subject=%s)", recipients, subject)
return True
except Exception as exc: # noqa: BLE001 — 메일 실패가 앱을 막으면 안 됨
logger.warning("메일 발송 실패 (subject=%s): %s", subject, exc)
return False
+145 -5
View File
@@ -13,9 +13,20 @@ from jinja2 import ChoiceLoader, FileSystemLoader
from pydantic import BaseModel
from starlette.middleware.sessions import SessionMiddleware
from .modules.expense import build_expense_store
from .modules.cupang import build_cupang_store, build_itemcode_reader
from .modules.cupang import router as cupang_router
from .modules.dispatch import build_dispatch_store
from .modules.dispatch import router as dispatch_router
from .modules.expense import CategoryStore, build_expense_store
from .modules.expense import router as expense_router
from .modules.malaysia import build_malaysia_itemcode, build_malaysia_store
from .modules.malaysia import router as malaysia_router
from .modules.project import build_project_store
from .modules.project import router as project_router
from .modules.vacation import build_vacation_store
from .modules.vacation import router as vacation_router
from .store import (
APPROVER_KEYS,
MODULE_KEYS,
SUPER_ADMIN_EMAIL,
UserStore,
@@ -24,6 +35,20 @@ from .store import (
is_admin,
)
# 권한 키 한글 라벨 (admin.html / 사이드바 공용)
MODULE_LABELS: dict[str, str] = {
"corm": "CORM",
"order": "Order",
"expense": "개인경비",
"vacation": "휴가",
"cupang": "쿠팡 밀크런",
"malaysia": "말레이시아 재고관리",
"dispatch": "말레이시아 배송",
"project": "프로젝트 관리",
"expense_approver": "개인경비",
"vacation_approver": "휴가",
}
# OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름.
SESSION_COOKIE_DEFAULT = "session"
@@ -90,6 +115,11 @@ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="stat
# 신규 모듈 추가 시 아래 리스트에 `BASE_DIR / "modules" / "<name>" / "templates"` 만 추가.
_MODULE_TEMPLATE_DIRS = [
BASE_DIR / "modules" / "expense" / "templates",
BASE_DIR / "modules" / "cupang" / "templates",
BASE_DIR / "modules" / "vacation" / "templates",
BASE_DIR / "modules" / "malaysia" / "templates",
BASE_DIR / "modules" / "dispatch" / "templates",
BASE_DIR / "modules" / "project" / "templates",
]
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
templates.env.loader = ChoiceLoader(
@@ -99,18 +129,55 @@ templates.env.loader = ChoiceLoader(
]
)
# dispatch 모듈 템플릿 필터: 배송사 로고 URL / 송장 조회 딥링크.
# {{ provider | courier_logo }} · {{ tracking | courier_track(provider) }}
from .modules.dispatch.store import ( # noqa: E402
courier_logo_url as _courier_logo_url,
)
from .modules.dispatch.store import ( # noqa: E402
courier_tracking_url as _courier_tracking_url,
)
templates.env.filters["courier_logo"] = _courier_logo_url
templates.env.filters["courier_track"] = _courier_tracking_url
oauth = build_google_oauth()
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)
# 말레이시아 재고관리: MALAYSIA_STOCK_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
# 상품명은 위 itemcode_reader(읽기 전용) 재사용.
app.state.malaysia_store = build_malaysia_store(dsn=env("MALAYSIA_STOCK_DB_URL") or None)
# 세트 BOM 은 itemcode_db(set_components)에서 읽는다(ITEMCODE_DB_URL 재사용).
app.state.malaysia_itemcode = build_malaysia_itemcode()
# 말레이시아 배송(TikTok 출고): DISPATCH_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
# 업로드 원본은 DATA_DIR/dispatch/ 아래 저장(개인정보는 저장하지 않음).
app.state.dispatch_store = build_dispatch_store(dsn=env("DISPATCH_DB_URL") or None)
# 프로젝트 관리(아사나식): PROJECT_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
# 메일 알림은 SMTP_* 환경변수 기반(app/mail.py). 미설정 시 조용히 skip.
app.state.project_store = build_project_store(dsn=env("PROJECT_DB_URL") or None)
# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄.
app.include_router(expense_router)
app.include_router(cupang_router)
app.include_router(vacation_router)
app.include_router(malaysia_router)
app.include_router(dispatch_router)
app.include_router(project_router)
def public_url_for(request: Request, route_name: str) -> str:
@@ -233,14 +300,54 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
"status": "ready",
"category": "관리",
},
{
"key": "cupang",
"title": "쿠팡 밀크런",
"subtitle": "Coupang Milk-run",
"description": "쿠팡 밀크런 출고 일정·박스 계산·입고센터를 달력에서 관리합니다.",
"url": "/cupang/",
"health_url": "/cupang/health",
"status": "ready",
"category": "운영",
},
{
"key": "vacation",
"title": "휴가",
"subtitle": "Vacation",
"description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 확인합니다.",
"url": "#",
"health_url": None,
"status": "preparing",
"description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 달력에서 관리합니다.",
"url": "/vacation/",
"health_url": "/vacation/health",
"status": "ready",
"category": "관리",
},
{
"key": "malaysia",
"title": "말레이시아 재고관리",
"subtitle": "Malaysia Stock",
"description": "말레이시아 창고 입출고·세트 BOM·일일 재고조사를 관리합니다.",
"url": "/malaysia/",
"health_url": "/malaysia/health",
"status": "ready",
"category": "운영",
},
{
"key": "dispatch",
"title": "말레이시아 배송",
"subtitle": "Malaysia Dispatch",
"description": "TikTok 출고 파일을 올리면 박스별 작업 리스트·피킹 요약·Kagayaku 전달표를 자동 생성합니다.",
"url": "/dispatch/",
"health_url": "/dispatch/health",
"status": "ready",
"category": "운영",
},
{
"key": "project",
"title": "프로젝트 관리",
"subtitle": "Projects",
"description": "프로젝트·서브프로젝트·업무를 달력/타임라인/보드로 관리합니다(아사나식).",
"url": "/project/",
"health_url": "/project/health",
"status": "ready",
"category": "관리",
},
]
@@ -258,6 +365,10 @@ def _icon_svg(name: str) -> str:
"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"/>',
"malaysia": '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
"dispatch": '<rect x="1" y="3" width="15" height="13"/><path d="M16 8h4l3 3v5h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/>',
"project": '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="13" x2="13" y2="13"/><line x1="8" y1="16" x2="11" y2="16"/>',
"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"])
@@ -301,6 +412,9 @@ def build_erp_nav(
"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
@@ -459,6 +573,8 @@ async def admin_page(request: Request) -> HTMLResponse:
"user": rec,
"users": users,
"module_keys": list(MODULE_KEYS),
"module_labels": MODULE_LABELS,
"approver_keys": list(APPROVER_KEYS),
"super_admin_email": SUPER_ADMIN_EMAIL,
"is_admin": True,
},
@@ -471,11 +587,35 @@ class UpdatePermissionsBody(BaseModel):
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")
async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse:
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}")
async def api_update_user(
email: str,
+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 %}
+47
View File
@@ -0,0 +1,47 @@
"""말레이시아 TikTok 출고관리(dispatch) 모듈.
라우터/저장소/파서/템플릿을 한 디렉토리에서 관리한다.
- 라우터: `router.py` (FastAPI APIRouter, prefix=/dispatch)
- 저장소: `db.py` (dispatch_db / PostgreSQL 전용)
- 파서: `parser.py` (TikTok Order Export XLSX → 박스 묶기, openpyxl)
- 순수 로직: `store.py` (컬럼 정규화/박스 묶기/SKU 합산/상태 상수)
- 템플릿: `templates/dispatch/`
데이터 저장은 dispatch_db 전용이다. DISPATCH_DB_URL 미설정 시
build_dispatch_store 는 None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를
보여준다(앱은 죽지 않음).
업로드 원본 파일은 DATA_DIR/dispatch/<날짜>/<배치>/ 아래에 저장하고, DB 에는
경로만 기록한다. 고객 이름/주소/전화 등 개인정보는 저장하지 않는다.
"""
from typing import Any
from . import store
from .parser import ParseError, parse_order_export
from .router import router
from .store import STATUS_FIELDS, STATUS_LABELS, group_parcels, sku_summary
__all__ = [
"router",
"store",
"STATUS_FIELDS",
"STATUS_LABELS",
"group_parcels",
"sku_summary",
"parse_order_export",
"ParseError",
"build_dispatch_store",
]
def build_dispatch_store(*, dsn: str | None) -> Any:
"""DISPATCH_DB_URL 이 있으면 DispatchStore, 없으면 None.
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 표시.
"""
if not dsn:
return None
from .db import DispatchStore # 지연 import (개발 환경 deps 없을 수 있음)
return DispatchStore(dsn)
+469
View File
@@ -0,0 +1,469 @@
"""dispatch_db PostgreSQL 저장소.
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — 다른 모듈과 동일 패턴.
- 연결 정보: 환경변수 `DISPATCH_DB_URL`
(예: postgresql://dispatch_app:<pwd>@postgres-db:5432/dispatch_db)
- 스키마는 앱이 만들지 않는다. `scripts/sql/dispatch_db_init.sql` 을 superuser 가
사전 적용한다. 앱 계정(dispatch_app)은 CRUD 권한만 받는다.
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
박스 묶기/SKU 합산 등 순수 로직은 `store.py` 에 있고, 여기서는 DB I/O 와
조립만 담당한다.
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from typing import Any
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from app.timezone import KST
from . import store
logger = logging.getLogger("dispatch.db")
class DispatchStore:
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 create_batch(
self,
*,
platform: str,
dispatch_date: str,
batch_name: str,
picking_pdf_path: str = "",
label_pdf_path: str = "",
order_export_path: str = "",
) -> dict[str, Any]:
if not (dispatch_date or "").strip():
raise ValueError("출고 날짜는 필수입니다.")
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO dispatch_batches
(platform, dispatch_date, batch_name,
picking_pdf_path, label_pdf_path, order_export_path)
VALUES (%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
(platform or "TikTok").strip(),
dispatch_date.strip(),
(batch_name or "").strip(),
(picking_pdf_path or "").strip(),
(label_pdf_path or "").strip(),
(order_export_path or "").strip(),
),
).fetchone()
return self._serialize(row)
def add_parcels(self, *, batch_id: int, parcels: list[dict[str, Any]]) -> int:
"""파서 결과(parcels)를 박스+상품으로 일괄 저장. 반환: 저장한 박스 수.
한 트랜잭션. 같은 박스 안 같은 SKU 는 파서가 이미 합산해서 들어온다.
"""
saved = 0
with self._pool.connection() as conn:
with conn.transaction():
for p in parcels:
parcel_row = conn.execute(
"""
INSERT INTO dispatch_parcels
(batch_id, seq, order_id, package_id,
tracking_id, shipping_provider,
recipient_name, recipient_phone, recipient_address)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""",
(
batch_id,
int(p.get("seq") or (saved + 1)),
(p.get("order_id") or "").strip(),
(p.get("package_id") or "").strip(),
(p.get("tracking_id") or "").strip(),
(p.get("shipping_provider") or "").strip(),
(p.get("recipient_name") or "").strip(),
(p.get("recipient_phone") or "").strip(),
(p.get("recipient_address") or "").strip(),
),
).fetchone()
parcel_id = parcel_row["id"]
for it in p.get("items", []):
conn.execute(
"""
INSERT INTO dispatch_items
(parcel_id, seller_sku, product_name, quantity)
VALUES (%s,%s,%s,%s)
""",
(
parcel_id,
(it.get("seller_sku") or "").strip(),
(it.get("product_name") or "").strip(),
int(it.get("quantity") or 1),
),
)
saved += 1
return saved
def list_batches(self, *, limit: int = 200) -> list[dict[str, Any]]:
"""배치 목록 + 박스 수/완료(택배스캔) 수 요약."""
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT b.*,
COUNT(p.id) AS parcel_count,
COUNT(p.id) FILTER (
WHERE p.label_attached AND p.handed_to_kagayaku
) AS done_count
FROM dispatch_batches b
LEFT JOIN dispatch_parcels p ON p.batch_id = b.id
GROUP BY b.id
ORDER BY b.dispatch_date DESC, b.id DESC
LIMIT %s
""",
(int(limit),),
).fetchall()
return [self._serialize(r) for r in rows]
def get_batch(self, *, batch_id: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM dispatch_batches WHERE id = %s", (batch_id,)
).fetchone()
return self._serialize(row) if row else None
def delete_batch(self, *, batch_id: int) -> None:
"""배치 + 하위 박스/상품/로그 전체 삭제(CASCADE)."""
with self._pool.connection() as conn:
cur = conn.execute("DELETE FROM dispatch_batches WHERE id = %s", (batch_id,))
if cur.rowcount == 0:
raise KeyError(batch_id)
# ════════════════════════════════════════════════════════════
# 박스(parcel)
# ════════════════════════════════════════════════════════════
def list_parcels(self, *, batch_id: int) -> list[dict[str, Any]]:
"""배치의 박스 전체 + 각 박스의 상품 목록(seq 순)."""
with self._pool.connection() as conn:
parcel_rows = conn.execute(
"SELECT * FROM dispatch_parcels WHERE batch_id = %s "
"ORDER BY seq ASC, id ASC",
(batch_id,),
).fetchall()
item_rows = conn.execute(
"""
SELECT i.* FROM dispatch_items i
JOIN dispatch_parcels p ON p.id = i.parcel_id
WHERE p.batch_id = %s
ORDER BY i.seller_sku ASC, i.id ASC
""",
(batch_id,),
).fetchall()
items_by_parcel: dict[int, list[dict[str, Any]]] = {}
for r in item_rows:
items_by_parcel.setdefault(r["parcel_id"], []).append(self._serialize(r))
parcels: list[dict[str, Any]] = []
for r in parcel_rows:
p = self._serialize(r)
p["items"] = items_by_parcel.get(r["id"], [])
parcels.append(p)
return parcels
def sku_summary(self, *, batch_id: int) -> list[dict[str, Any]]:
"""배치 전체 SKU별 총 수량(피킹 요약). seller_sku 오름차순."""
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT i.seller_sku,
MAX(i.product_name) AS product_name,
SUM(i.quantity)::int AS total_qty
FROM dispatch_items i
JOIN dispatch_parcels p ON p.id = i.parcel_id
WHERE p.batch_id = %s
GROUP BY i.seller_sku
ORDER BY i.seller_sku ASC
""",
(batch_id,),
).fetchall()
return [
{
"seller_sku": r["seller_sku"],
"product_name": r["product_name"] or "",
"total_qty": int(r["total_qty"] or 0),
}
for r in rows
]
def toggle_status(
self, *, parcel_id: int, field: str, worker_name: str = ""
) -> dict[str, Any]:
"""박스의 작업 상태 boolean 1개를 토글하고 로그를 남긴다.
반환: {"parcel_id", "field", "value"(토글 후 값)}.
field 는 store.STATUS_FIELDS 화이트리스트 검증(SQL 식별자 안전).
"""
if field not in store.STATUS_FIELDS:
raise ValueError(f"허용되지 않는 작업 상태: {field}")
with self._pool.connection() as conn:
with conn.transaction():
cur = conn.execute(
# field 는 화이트리스트라 인젝션 위험 없음. RETURNING 으로 새 값 확인.
f"UPDATE dispatch_parcels SET {field} = NOT {field} "
"WHERE id = %s RETURNING " + field,
(parcel_id,),
).fetchone()
if cur is None:
raise KeyError(parcel_id)
new_value = bool(cur[field])
conn.execute(
"""
INSERT INTO dispatch_logs
(parcel_id, action, old_value, new_value, worker_name)
VALUES (%s,%s,%s,%s,%s)
""",
(
parcel_id,
field,
str(not new_value).lower(),
str(new_value).lower(),
(worker_name or "").lower().strip(),
),
)
return {"parcel_id": parcel_id, "field": field, "value": new_value}
def bulk_set_status(
self, *, batch_id: int, field: str, value: bool = True, worker_name: str = ""
) -> int:
"""배치 내 모든 박스의 작업 상태 1개를 value 로 일괄 설정. 변경된 박스만 로그.
반환: 실제로 바뀐 박스 수(이미 value 인 박스는 건드리지 않음).
field 는 store.STATUS_FIELDS 화이트리스트 검증(SQL 식별자 안전).
"""
if field not in store.STATUS_FIELDS:
raise ValueError(f"허용되지 않는 작업 상태: {field}")
with self._pool.connection() as conn:
with conn.transaction():
rows = conn.execute(
# field 는 화이트리스트라 인젝션 위험 없음.
f"UPDATE dispatch_parcels SET {field} = %s "
f"WHERE batch_id = %s AND {field} = %s RETURNING id",
(value, batch_id, not value),
).fetchall()
ids = [r["id"] for r in rows]
if ids:
conn.execute(
"""
INSERT INTO dispatch_logs
(parcel_id, action, old_value, new_value, worker_name)
SELECT unnest(%s::bigint[]), %s, %s, %s, %s
""",
(
ids,
field,
str(not value).lower(),
str(value).lower(),
(worker_name or "").lower().strip(),
),
)
return len(ids)
def stock_aggregate_for_date(self, *, dispatch_date: str) -> list[dict[str, Any]]:
"""해당 날짜 모든 배치의 SKU별 합산 수량.
반환: [{"seller_sku", "qty"}]. _N 배수/콤보 분해는 호출부(export)에서 적용.
"""
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT i.seller_sku, SUM(i.quantity)::int AS qty
FROM dispatch_items i
JOIN dispatch_parcels p ON p.id = i.parcel_id
JOIN dispatch_batches b ON b.id = p.batch_id
WHERE b.dispatch_date = %s
GROUP BY i.seller_sku
ORDER BY i.seller_sku ASC
""",
(dispatch_date,),
).fetchall()
return [
{"seller_sku": r["seller_sku"] or "", "qty": int(r["qty"] or 0)}
for r in rows
]
# ════════════════════════════════════════════════════════════
# 말레이시아 재고 차감 1회성 가드
# ════════════════════════════════════════════════════════════
def deduction_exists(self, *, dispatch_date: str) -> bool:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT 1 FROM dispatch_stock_deductions WHERE dispatch_date = %s",
(dispatch_date,),
).fetchone()
return row is not None
def list_deducted_dates(self) -> list[str]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT dispatch_date FROM dispatch_stock_deductions "
"ORDER BY dispatch_date DESC"
).fetchall()
out: list[str] = []
for r in rows:
d = r["dispatch_date"]
out.append(d.isoformat() if isinstance(d, date) else str(d))
return out
def record_deduction(
self,
*,
dispatch_date: str,
warehouse_code: str,
single_lines: int,
combo_lines: int,
worker_name: str = "",
) -> None:
"""차감 완료 기록. 이미 있으면 충돌(재차감 차단)."""
with self._pool.connection() as conn:
conn.execute(
"""
INSERT INTO dispatch_stock_deductions
(dispatch_date, warehouse_code, single_lines, combo_lines, worker_name)
VALUES (%s,%s,%s,%s,%s)
""",
(
dispatch_date,
(warehouse_code or "").strip(),
int(single_lines),
int(combo_lines),
(worker_name or "").lower().strip(),
),
)
def search_parcels(self, *, query: str, limit: int = 50) -> list[dict[str, Any]]:
"""주문번호/패키지번호/송장번호/받는 사람 이름(부분 일치)로 박스를 찾는다.
4개 필드 어디든 query 가 포함되면 매칭. 받는 사람(이름/전화/주소)과
소속 배치(날짜/플랫폼/배치명/id) + 박스 seq 를 함께 돌려준다.
최신 출고일/배치 우선. query 가 비면 빈 리스트.
"""
q = (query or "").strip()
if not q:
return []
like = f"%{q}%"
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT p.id AS parcel_id,
p.seq,
p.order_id,
p.package_id,
p.tracking_id,
p.shipping_provider,
p.recipient_name,
p.recipient_phone,
p.recipient_address,
p.label_attached,
p.handed_to_kagayaku,
b.id AS batch_id,
b.dispatch_date,
b.platform,
b.batch_name
FROM dispatch_parcels p
JOIN dispatch_batches b ON b.id = p.batch_id
WHERE p.order_id ILIKE %(like)s
OR p.package_id ILIKE %(like)s
OR p.tracking_id ILIKE %(like)s
OR p.recipient_name ILIKE %(like)s
ORDER BY b.dispatch_date DESC, b.id DESC, p.seq ASC
LIMIT %(limit)s
""",
{"like": like, "limit": int(limit)},
).fetchall()
return [self._serialize(r) for r in rows]
def parcel_batch_id(self, *, parcel_id: int) -> int | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT batch_id FROM dispatch_parcels WHERE id = %s", (parcel_id,)
).fetchone()
return int(row["batch_id"]) if row else None
# ════════════════════════════════════════════════════════════
# Kagayaku 전달 요약
# ════════════════════════════════════════════════════════════
def handover_summary(self, *, batch_id: int) -> dict[str, Any]:
"""배송사별 박스 수 + Tracking ID 목록(전달 리스트/인쇄용)."""
with self._pool.connection() as conn:
provider_rows = conn.execute(
"""
SELECT COALESCE(NULLIF(shipping_provider, ''), '(미지정)') AS provider,
COUNT(*)::int AS box_count
FROM dispatch_parcels
WHERE batch_id = %s
GROUP BY provider
ORDER BY box_count DESC, provider ASC
""",
(batch_id,),
).fetchall()
tracking_rows = conn.execute(
"""
SELECT seq, order_id, tracking_id, shipping_provider
FROM dispatch_parcels
WHERE batch_id = %s
ORDER BY seq ASC, id ASC
""",
(batch_id,),
).fetchall()
total = sum(int(r["box_count"]) for r in provider_rows)
return {
"total_boxes": total,
"by_provider": [
{"provider": r["provider"], "box_count": int(r["box_count"])}
for r in provider_rows
],
"tracking_list": [self._serialize(r) for r in tracking_rows],
}
# ════════════════════════════════════════════════════════════
# 직렬화
# ════════════════════════════════════════════════════════════
@staticmethod
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
for k, v in list(out.items()):
if isinstance(v, datetime):
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
elif isinstance(v, date):
out[k] = v.isoformat()
for k in ("id", "batch_id", "parcel_id", "seq", "quantity",
"parcel_count", "done_count", "total_qty", "box_count"):
if k in out and out[k] is not None:
try:
out[k] = int(out[k])
except (TypeError, ValueError):
pass
for k in store.STATUS_FIELDS:
if k in out:
out[k] = bool(out[k])
return out
+279
View File
@@ -0,0 +1,279 @@
"""출고 배치 → 취합 엑셀(openpyxl) 생성.
다운로드 zip 에 업로드 원본과 함께 넣는 '출고 엑셀'을 만든다. 1박스 안에 여러
SKU 가 있으면 SKU 당 1행으로 펼치고 박스 단위(받는 사람/주문/택배) 정보는 반복한다.
파일명 형식: 2026.06.22(Mon)_tictoc.xlsx (Shopee 는 끝이 _shopee).
- TikTok 은 운영 요청 철자에 맞춰 'tictoc' 을 쓴다.
"""
from __future__ import annotations
import io
import re
from datetime import date, datetime
from typing import Any
# 엑셀 컬럼(요청 순서). (헤더, 박스/배치/상품 키)
_COLUMNS: tuple[tuple[str, str], ...] = (
("발송 날짜", "dispatch_date"),
("고객 이름", "recipient_name"),
("전화번호", "recipient_phone"),
("주소", "recipient_address"),
("상품이름", "product_name"),
("아이템 코드", "seller_sku"),
("주문 수량", "quantity"),
("오더번호", "order_id"),
("택배사", "shipping_provider"),
("택배송장번호", "tracking_id"),
("주문처", "platform"),
)
# 플랫폼 → 파일명 접미사.
_PLATFORM_SUFFIX: dict[str, str] = {
"TikTok": "tictoc",
"Shopee": "shopee",
}
def export_filename(*, dispatch_date: str, platform: str) -> str:
"""2026.06.22(Mon)_tictoc.xlsx 형식 파일명. 날짜 파싱 실패 시 원문 사용."""
d = _parse_date(dispatch_date)
if d is not None:
stamp = d.strftime("%Y.%m.%d(%a)")
else:
stamp = (dispatch_date or "").strip() or "dispatch"
suffix = _PLATFORM_SUFFIX.get((platform or "").strip(), (platform or "dispatch").strip().lower())
return f"{stamp}_{suffix}.xlsx"
def split_sku(seller_sku: str) -> tuple[str, int]:
"""seller_sku → (아이템코드, 배수).
끝의 `_<숫자>` 는 '같은 상품 N개'를 뜻한다(예: MT-0320_3 → 코드 MT-0320, 3개).
접미사가 없으면 배수 1.
"""
s = (seller_sku or "").strip()
m = re.search(r"_(\d+)$", s)
if m:
return s[: m.start()], int(m.group(1))
return s, 1
def resolve_product_name(seller_sku: str, name_map: dict[str, str], fallback: str = "") -> str:
"""아이템코드(seller_sku)로 itemcode_db 상품명을 찾는다.
seller_sku 는 변형 접미사가 붙을 수 있다(예: MT-0320_3 → DB 는 MT-0320).
정확 일치 → 접미사(_숫자) 제거 후 일치 → 없으면 fallback(파싱된 상품명).
"""
key = (seller_sku or "").strip().upper()
if not key:
return fallback
if key in name_map:
return name_map[key]
base = re.sub(r"_\d+$", "", key)
if base != key and base in name_map:
return name_map[base]
return fallback
def build_workbook_bytes(
*,
batch: dict[str, Any],
parcels: list[dict[str, Any]],
name_map: dict[str, str] | None = None,
) -> bytes:
"""배치 + 박스(상품 포함) → xlsx 바이트.
parcels 각 항목은 db.list_parcels 결과(items 포함, recipient_* 포함).
name_map: itemcode_db 의 {코드(대문자): 상품명}. 있으면 아이템코드로 상품명을
조회해 채운다(없으면 파싱된 상품명 사용).
"""
name_map = name_map or {}
from openpyxl import Workbook # noqa: WPS433 — 지연 import
from openpyxl.styles import Font
wb = Workbook()
ws = wb.active
ws.title = "출고"
headers = [h for h, _ in _COLUMNS]
ws.append(headers)
for cell in ws[1]:
cell.font = Font(bold=True)
dispatch_date = batch.get("dispatch_date", "") or ""
platform = batch.get("platform", "") or ""
for p in parcels:
box = {
"dispatch_date": dispatch_date,
"platform": platform,
"recipient_name": p.get("recipient_name", "") or "",
"recipient_phone": p.get("recipient_phone", "") or "",
"recipient_address": p.get("recipient_address", "") or "",
"order_id": p.get("order_id", "") or "",
"shipping_provider": p.get("shipping_provider", "") or "",
"tracking_id": p.get("tracking_id", "") or "",
}
items = p.get("items") or [{}]
for it in items:
row_data = dict(box)
sku = it.get("seller_sku", "") or ""
base_code, mult = split_sku(sku)
row_data["product_name"] = resolve_product_name(
base_code, name_map, fallback=it.get("product_name", "") or ""
)
row_data["seller_sku"] = base_code # 접미사(_N) 제거한 실제 아이템코드
qty = it.get("quantity")
row_data["quantity"] = (int(qty) * mult) if qty is not None else ""
ws.append([_cell(row_data.get(key, "")) for _, key in _COLUMNS])
_autosize(ws, headers)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
# ── 사방넷 재고 업로드 엑셀(낱개 분해) ──
_STOCK_HEADERS: tuple[str, ...] = ("상품코드[필수]", "가용수량", "불용수량", "바코드")
_LID_PREFIX = "MD-" # 뚜껑 — 재고 집계 제외
_SET_PREFIX = "MY-" # 콤보(세트)
def split_singles_combos(
rows: list[dict[str, Any]],
) -> tuple[dict[str, int], dict[str, int]]:
"""SKU별 합산 행 → (낱개 {code: qty}, 콤보 {code: qty}).
_N 배수 반영, MD-(뚜껑) 제외. 콤보는 MY- 접두사. 분해는 하지 않는다
(말레이시아 재고관리가 세트 OUT 시 BOM 으로 분해하므로 콤보 그대로 넘긴다).
"""
singles: dict[str, int] = {}
combos: dict[str, int] = {}
for r in rows:
base, mult = split_sku(r.get("seller_sku", ""))
base = base.upper()
if not base:
continue
pieces = int(r.get("qty", 0)) * mult
if pieces <= 0 or base.startswith(_LID_PREFIX):
continue
if base.startswith(_SET_PREFIX):
combos[base] = combos.get(base, 0) + pieces
else:
singles[base] = singles.get(base, 0) + pieces
return singles, combos
def explode_to_singles(
rows: list[dict[str, Any]],
set_bom: dict[str, list[dict[str, Any]]],
) -> dict[str, int]:
"""SKU별 합산 행 → 낱개 코드별 수량.
- seller_sku 의 _N 접미사는 배수(같은 상품 N개)로 곱한다.
- 콤보(세트, set_bom 에 존재)는 구성 낱개로 분해해 (배수 × 구성수량) 더한다.
- MD-(뚜껑)은 재고 집계에서 제외한다.
반환: {낱개코드(대문자): 수량}.
"""
singles: dict[str, int] = {}
for r in rows:
base, mult = split_sku(r.get("seller_sku", ""))
base = base.upper()
if not base:
continue
pieces = int(r.get("qty", 0)) * mult
if pieces <= 0 or base.startswith(_LID_PREFIX):
continue
comps = set_bom.get(base)
if comps: # 콤보 → 낱개 분해
for c in comps:
cc = str(c.get("component_code", "")).strip().upper()
if not cc or cc.startswith(_LID_PREFIX):
continue
singles[cc] = singles.get(cc, 0) + pieces * int(c.get("component_qty", 0) or 0)
else:
singles[base] = singles.get(base, 0) + pieces
return {k: v for k, v in singles.items() if v > 0}
def build_stock_xlsx(
*,
rows: list[dict[str, Any]],
set_bom: dict[str, list[dict[str, Any]]],
sabangnet_map: dict[str, str],
) -> bytes:
"""사방넷 재고 업로드용 xlsx(낱개 분해). 4열: 상품코드[필수]/가용수량/불용수량/바코드.
A=사방넷코드, B=수량, C=0(불용), D=아이템코드(바코드). 코드 오름차순.
"""
from openpyxl import Workbook # noqa: WPS433
from openpyxl.styles import Alignment, Font, PatternFill
singles = explode_to_singles(rows, set_bom)
wb = Workbook()
ws = wb.active
ws.title = "재고"
ws.append(list(_STOCK_HEADERS))
head_fill = PatternFill("solid", fgColor="C55A11") # 사방넷 템플릿 주황
head_font = Font(bold=True, color="FFFFFF")
for cell in ws[1]:
cell.fill = head_fill
cell.font = head_font
cell.alignment = Alignment(horizontal="center", vertical="center")
for code in sorted(singles):
sabangnet = sabangnet_map.get(code, "")
ws.append([sabangnet, singles[code], 0, code])
widths = [22, 12, 12, 18]
from openpyxl.utils import get_column_letter
for i, w in enumerate(widths, start=1):
ws.column_dimensions[get_column_letter(i)].width = w
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def stock_filename(dispatch_date: str) -> str:
"""사방넷 출고 엑셀 파일명: 2026.06.22(Mon)_sabangnet_outbound.xlsx."""
d = _parse_date(dispatch_date)
stamp = d.strftime("%Y.%m.%d(%a)") if d else (dispatch_date or "outbound").strip()
return f"{stamp}_sabangnet_outbound.xlsx"
def _cell(value: Any) -> Any:
"""송장/전화번호 등이 숫자로 보이면 엑셀이 과학표기/반올림 하므로 문자열 보존."""
if isinstance(value, int):
return value
return "" if value is None else str(value)
def _autosize(ws: Any, headers: list[str]) -> None:
"""대략적인 컬럼 폭. 주소는 길어 상한을 둔다."""
from openpyxl.utils import get_column_letter
widths: list[int] = [len(h) for h in headers]
for row in ws.iter_rows(min_row=2, values_only=True):
for i, val in enumerate(row):
ln = len(str(val)) if val is not None else 0
if ln > widths[i]:
widths[i] = ln
for i, w in enumerate(widths, start=1):
ws.column_dimensions[get_column_letter(i)].width = min(max(w + 2, 8), 50)
def _parse_date(value: str) -> date | None:
s = (value or "").strip()
if not s:
return None
for fmt in ("%Y-%m-%d", "%Y.%m.%d", "%Y/%m/%d"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
return None
+264
View File
@@ -0,0 +1,264 @@
"""출고 XLSX 파서 (openpyxl) — TikTok / Shopee 공용.
pandas 대신 레포에 이미 있는 openpyxl 로 읽어 의존성을 가볍게 유지한다
(동작: 헤더 정규화 → 표준키 매핑 → 박스 묶기).
- TikTok: 03_TikTok_Order_Export.xlsx, Shopee: Packing List.Doorstep Delivery.xlsx.
두 포맷 모두 같은 표준키로 매핑된다(store.COLUMN_ALIASES). 헤더가 첫 행이 아닐
수 있어(Shopee 는 제목 행이 위에 붙는 경우가 있다) 상단 몇 행을 훑어 필수 컬럼이
잡히는 첫 행을 헤더로 본다.
- 받는 사람(이름/전화/주소) 컬럼은 매핑되면 보존한다(작업 카드 표시 + 출고 엑셀).
- Tracking ID 는 숫자로 읽혀도 문자열로 보존(store.clean_text).
- Quantity 가 비면 1, NaN/None 은 빈 문자열로 처리.
- 필수 컬럼이 없으면 어떤 컬럼이 없는지 ParseError 로 알린다.
"""
from __future__ import annotations
from typing import Any
from . import store
class ParseError(Exception):
"""엑셀 파싱 실패. message 는 사용자에게 그대로 보여줄 한국어 메시지."""
# 헤더가 첫 행이 아닐 수 있어 상단에서 헤더 행을 찾을 때 훑는 최대 행 수.
_HEADER_SCAN_ROWS = 15
# Shopee SPX 송장번호 접두사 → 배송사. xlsx 에 배송사 컬럼이 없어 송장으로 추론.
_SHOPEE_DEFAULT_PROVIDER = "SPX"
def parse_export(path: str, platform: str = "") -> dict[str, Any]:
"""출고 XLSX 경로 → {"parcels": [...], "row_count": int}.
parcels 는 store.group_parcels 결과(1박스=1항목). 실패 시 ParseError.
- Shopee: SKU/수량이 product_info 한 셀에 묶여 있어 별도 경로.
- Manual: 받는 사람/상품/수량이 첫 시트 한 행에 있는 수동 오더(1행=1박스).
"""
p = (platform or "").strip()
if p == store.PLATFORM_SHOPEE:
return _parse_shopee(path)
if p == store.PLATFORM_MANUAL:
return _parse_manual(path)
return _parse_generic(path)
# 메뉴얼 오더 엑셀 컬럼 별칭(헤더 정규화 후 비교). 받는 사람 정보가 엑셀에 직접 있다.
_MANUAL_ALIASES: dict[str, tuple[str, ...]] = {
"recipient_name": ("receiver name", "recipient name", "recipient", "받는 사람", "받는사람", "수취인", "이름", "name"),
"recipient_phone": ("phone", "phone number", "phone #", "contact", "전화", "전화번호", "연락처"),
"recipient_address": ("address", "delivery address", "주소", "배송지", "배송주소"),
"product_name": ("product name", "item name", "product", "상품명", "상품이름"),
"seller_sku": ("item code", "seller sku", "sku", "아이템코드", "아이템 코드", "상품코드", "코드"),
"quantity": ("quantity", "qty", "수량", "주문수량"),
}
def _parse_manual(path: str) -> dict[str, Any]:
"""메뉴얼 오더 엑셀(첫 시트). 1행 = 1박스.
컬럼: Receiver Name / Phone / Address / Product Name / Item Code / Quantity.
받는 사람 정보가 엑셀에 직접 있어 PDF 없이 카드/엑셀에 표시된다. 묶음키
(Order/Tracking/Package)가 없으므로 행마다 별도 박스(seq)로 만든다.
"""
all_rows = _load_rows(path)
header_idx = None
mapping: dict[str, int] = {}
for idx, raw in enumerate(all_rows[:_HEADER_SCAN_ROWS]):
if raw is None:
continue
m = {
key: store.find_column(raw, aliases)
for key, aliases in _MANUAL_ALIASES.items()
}
# 최소: 아이템코드 컬럼이 잡혀야 한다.
if m.get("seller_sku") is not None:
header_idx = idx
mapping = {k: v for k, v in m.items() if v is not None}
break
if header_idx is None:
raise ParseError(
"메뉴얼 오더 엑셀에서 컬럼을 찾지 못했습니다. "
"필요 컬럼: Item Code(필수), Receiver Name, Phone, Address, Product Name, Quantity"
)
def cell(raw: Any, key: str) -> str:
idx = mapping.get(key)
if idx is None or idx >= len(raw):
return ""
return store.clean_text(raw[idx])
parcels: list[dict[str, Any]] = []
seq = 0
for raw in all_rows[header_idx + 1:]:
if raw is None:
continue
sku = cell(raw, "seller_sku")
name = cell(raw, "recipient_name")
if not sku and not name:
continue # 빈 행
if not sku:
continue # 아이템코드 없으면 출고 대상 아님
seq += 1
parcels.append(
{
"seq": seq,
"order_id": "",
"package_id": "",
"tracking_id": "",
"shipping_provider": "",
"recipient_name": name,
"recipient_phone": cell(raw, "recipient_phone"),
"recipient_address": cell(raw, "recipient_address"),
"items": [
{
"seller_sku": sku,
"product_name": cell(raw, "product_name"),
"quantity": store.normalize_quantity(cell(raw, "quantity")),
}
],
}
)
return {"parcels": parcels, "row_count": len(parcels)}
def _load_rows(path: str) -> list:
"""첫 시트 전체 행(values_only). 열기/빈 파일 예외는 ParseError 로."""
try:
from openpyxl import load_workbook # noqa: WPS433 — 지연 import
except ImportError as exc: # pragma: no cover
raise ParseError("openpyxl 이 설치되어 있지 않습니다.") from exc
# read_only=True 는 쓰지 않는다: TikTok export 는 워크시트 <dimension> 메타가
# 부실해 read_only 모드가 A열만 읽고 끊기는 openpyxl 버그가 있다(실제 54열인데
# 1열만 반환). 일일 출고량(수백~수천 행)이라 일반 모드로 충분하다.
try:
wb = load_workbook(path, data_only=True)
except Exception as exc: # noqa: BLE001
raise ParseError(f"엑셀 파일을 열 수 없습니다: {type(exc).__name__}") from exc
try:
rows = list(wb.worksheets[0].iter_rows(values_only=True))
finally:
wb.close()
if not rows:
raise ParseError("엑셀에 데이터가 없습니다(빈 파일).")
return rows
def _parse_generic(path: str) -> dict[str, Any]:
"""TikTok 등 표준 컬럼형 엑셀: 헤더 행 자동탐지 → 표준키 매핑 → 박스 묶기."""
all_rows = _load_rows(path)
header_idx, mapping = _find_header(all_rows)
if header_idx is None:
missing = store.missing_required({})
raise ParseError("엑셀에서 필요한 컬럼을 찾지 못했습니다: " + ", ".join(missing))
norm_rows: list[dict[str, str]] = []
for raw in all_rows[header_idx + 1:]:
if raw is None:
continue
# 표준키만 추출(매핑 안 된 열은 버린다).
record = {
key: store.clean_text(raw[idx]) if idx < len(raw) else ""
for key, idx in mapping.items()
}
# 완전 빈 행 스킵
if not any(record.get(k) for k in ("order_id", "package_id", "tracking_id", "seller_sku")):
continue
# 헤더 바로 아래 '컬럼 설명' 행 스킵(TikTok export 2번째 행).
# 주문/송장/패키지 ID 는 공백을 포함하지 않는다. 설명 문장은 공백을
# 포함하므로, 채워진 ID 값에 공백이 있으면 데이터가 아니라 설명 행이다.
if _is_description_row(record):
continue
norm_rows.append(record)
parcels = store.group_parcels(norm_rows)
return {"parcels": parcels, "row_count": len(norm_rows)}
def _parse_shopee(path: str) -> dict[str, Any]:
"""Shopee Packing List.Doorstep Delivery.xlsx 파싱.
컬럼: order_sn, tracking_number, product_info(상품 N개가 한 셀에 묶임).
1행 = 1주문 = 1박스. product_info 를 펼쳐 상품별 행을 만든 뒤 박스로 묶는다.
이름/주소/전화는 xlsx 에 없으므로 라벨 PDF 단계에서 채운다(여기선 빈 값).
"""
all_rows = _load_rows(path)
header_idx = order_idx = track_idx = pinfo_idx = None
for idx, raw in enumerate(all_rows[:_HEADER_SCAN_ROWS]):
if raw is None:
continue
oi = store.find_column(raw, store.COLUMN_ALIASES["order_id"])
ti = store.find_column(raw, store.COLUMN_ALIASES["tracking_id"])
pi = store.find_column(raw, store.PRODUCT_INFO_ALIASES)
if pi is not None and (oi is not None or ti is not None):
header_idx, order_idx, track_idx, pinfo_idx = idx, oi, ti, pi
break
if header_idx is None:
raise ParseError(
"Shopee 엑셀에서 필요한 컬럼을 찾지 못했습니다: "
"product_info + (order_sn 또는 tracking_number)"
)
norm_rows: list[dict[str, str]] = []
for raw in all_rows[header_idx + 1:]:
if raw is None:
continue
order = store.clean_text(raw[order_idx]) if order_idx is not None and order_idx < len(raw) else ""
tracking = store.clean_text(raw[track_idx]) if track_idx is not None and track_idx < len(raw) else ""
blob = raw[pinfo_idx] if pinfo_idx < len(raw) else ""
if not (order or tracking):
continue
for it in store.parse_shopee_product_info(blob):
norm_rows.append(
{
"order_id": order,
"tracking_id": tracking,
"shipping_provider": _SHOPEE_DEFAULT_PROVIDER,
"seller_sku": it["seller_sku"],
"product_name": it["product_name"],
"quantity": it["quantity"],
}
)
parcels = store.group_parcels(norm_rows)
return {"parcels": parcels, "row_count": len(norm_rows)}
# 하위 호환 별칭(기존 호출부/테스트가 쓰던 이름).
parse_order_export = parse_export
def _find_header(rows: list) -> tuple[int | None, dict[str, int]]:
"""상단 행들을 훑어 필수 컬럼이 잡히는 첫 행을 헤더로 본다.
반환: (헤더 행 인덱스, {표준키: 열 인덱스}). 못 찾으면 (None, {}).
"""
for idx, raw in enumerate(rows[:_HEADER_SCAN_ROWS]):
if raw is None:
continue
mapping = store.resolve_columns(raw)
if not store.missing_required(mapping):
return idx, mapping
return None, {}
def _is_description_row(record: dict[str, str]) -> bool:
"""헤더 아래 '컬럼 설명' 행 판별.
Order/Tracking/Package ID 는 공백 없는 식별자다. 채워진 ID 값 중 하나라도
공백을 포함하면(예: 'Platform unique order ID.') 데이터가 아닌 설명 행이다.
"""
for key in ("order_id", "tracking_id", "package_id"):
val = (record.get(key) or "").strip()
if val and any(ch.isspace() for ch in val):
return True
return False
+209
View File
@@ -0,0 +1,209 @@
"""라벨 PDF → 주문별 받는 사람(이름/전화/주소) 추출 (pdfplumber).
xlsx 는 박스/SKU/수량/주문/송장을 담당하고, 받는 사람 개인정보는 가려져 있거나
컬럼이 없어 라벨 PDF 에서 가져온다. 추출 결과는 Order ID 로 박스와 조인한다.
- Shopee(SPX 라벨): 1주문 = 라벨 1p + 패킹리스트 1p. 라벨 페이지에
'Recipient Details (Penerima)' 블록이 있다. 2단 레이아웃이라 왼쪽 열만 본다.
전화는 표시되지 않는다(빈 값).
- TikTok: 1주문 = 1p. 'To <이름> (+60)<전화>' 줄 + 그 아래 주소. 전화는 일부
마스킹될 수 있다(보이는 그대로 보존).
좌표 의존 파서라 라벨 양식이 바뀌면 조정이 필요하다(상단 상수만 손보면 됨).
"""
from __future__ import annotations
import logging
import re
from typing import Any
logger = logging.getLogger("dispatch.pdf")
# Shopee 라벨 2단 중 왼쪽 열 경계(x0 < 값). 오른쪽 열의 바코드/코드(SPXMY…,
# Seller Details, KV6-…)를 배제한다.
_SHOPEE_LEFT_MAX = 185
# 같은 줄로 묶을 top 좌표 허용 오차(px).
_LINE_TOL = 3
# 전화번호 토큰: (+60)193819581 / (+60)11******36 등.
_PHONE_RE = re.compile(r"\(\+?\d[\d()*\- ]*")
class PdfParseError(Exception):
"""PDF 파싱 실패. message 는 사용자에게 보여줄 한국어 메시지."""
def extract_recipients(path: str, platform: str) -> dict[str, dict[str, str]]:
"""라벨 PDF → {order_id: {recipient_name, recipient_phone, recipient_address}}.
실패해도 빈 dict 를 반환할 수 있다(호출부는 PII 없이 진행). 열기 자체 실패는
PdfParseError.
"""
try:
import pdfplumber # noqa: WPS433 — 지연 import
except ImportError as exc: # pragma: no cover
raise PdfParseError("pdfplumber 가 설치되어 있지 않습니다.") from exc
try:
pdf = pdfplumber.open(path)
except Exception as exc: # noqa: BLE001
raise PdfParseError(f"PDF 를 열 수 없습니다: {type(exc).__name__}") from exc
out: dict[str, dict[str, str]] = {}
try:
for page in pdf.pages:
try:
words = page.extract_words()
except Exception: # noqa: BLE001
continue
if not words:
continue
if platform == "Shopee":
rec = _shopee_page(words)
else:
rec = _tiktok_page(words)
if rec and rec.get("order_id"):
oid = rec.pop("order_id")
# 이미 있으면 빈 값만 보강(중복 페이지 방어).
cur = out.setdefault(oid, {"recipient_name": "", "recipient_phone": "", "recipient_address": ""})
for k, v in rec.items():
if v and not cur.get(k):
cur[k] = v
finally:
pdf.close()
return out
# ────────────────────────────────────────────────────────────
# 줄 단위 헬퍼
# ────────────────────────────────────────────────────────────
def _lines(words: list[dict[str, Any]], *, x_max: float | None = None) -> list[tuple[float, list[dict[str, Any]]]]:
"""단어들을 top 좌표로 묶어 줄 리스트로. 각 줄: (top, [word,...]) x0 오름차순."""
ws = [w for w in words if x_max is None or w["x0"] < x_max]
ws.sort(key=lambda w: (round(w["top"]), w["x0"]))
lines: list[tuple[float, list[dict[str, Any]]]] = []
for w in ws:
if lines and abs(w["top"] - lines[-1][0]) <= _LINE_TOL:
lines[-1][1].append(w)
else:
lines.append((w["top"], [w]))
return lines
def _text(line_words: list[dict[str, Any]]) -> str:
return " ".join(w["text"] for w in sorted(line_words, key=lambda w: w["x0"])).strip()
# ────────────────────────────────────────────────────────────
# Shopee 라벨 페이지
# ────────────────────────────────────────────────────────────
def _shopee_page(words: list[dict[str, Any]]) -> dict[str, str] | None:
lines = _lines(words, x_max=_SHOPEE_LEFT_MAX)
texts = [_text(lw) for _, lw in lines]
joined = " ".join(texts)
if "Recipient Details" not in joined and "Penerima" not in joined:
return None # 라벨 페이지 아님(패킹리스트 등)
order_id = ""
rec_idx = None
for i, t in enumerate(texts):
if not order_id:
m = re.search(r"Order\s*ID\s*:\s*([A-Za-z0-9]+)", t)
if m:
order_id = m.group(1)
if rec_idx is None and ("Recipient Details" in t or "Penerima" in t):
rec_idx = i
if rec_idx is None:
return None
name = ""
addr_parts: list[str] = []
postcode = ""
collecting = False
for t in texts[rec_idx + 1:]:
if t.startswith("Name:"):
name = t[len("Name:"):].strip()
continue
if t.startswith("Address:"):
collecting = True
addr_parts.append(t[len("Address:"):].strip())
continue
if t.startswith("Postcode:"):
postcode = t[len("Postcode:"):].strip()
break # 받는 사람 우편번호에서 주소 끝
if collecting:
# 프로모/푸터 시작 전까지 주소 줄로 본다.
if t.startswith(("Select", "Scan", "Seller Details")):
break
addr_parts.append(t)
address = ", ".join(p for p in addr_parts if p)
if postcode and postcode not in address:
address = f"{address}, {postcode}" if address else postcode
address = _tidy(address)
return {"order_id": order_id, "recipient_name": name, "recipient_phone": "", "recipient_address": address}
# ────────────────────────────────────────────────────────────
# TikTok 라벨 페이지
# ────────────────────────────────────────────────────────────
# 주소 수집을 멈추는 키워드(섹션 경계). TikTok 라벨은 배송사마다 양식이 달라
# 넉넉히 둔다.
_TIKTOK_STOP = ("CASHLESS", "CASH ON", "COD", "Order ID", "Shipping Date",
"Product Name", "Estimated", "In transit", "DROP-OFF", "PICK-UP",
"Self Collect", "Order Created", "Return for", "Scan me", "Sender")
# 받는 사람 앵커: 'To <이름> (+60)…'(Ninja) 또는 'Receiver <이름>'(NDD 등).
_TIKTOK_ANCHOR = re.compile(r"^(To|Receiver)\b\s*(.*)$")
def _tiktok_page(words: list[dict[str, Any]]) -> dict[str, str] | None:
lines = _lines(words)
texts = [_text(lw) for _, lw in lines]
order_id = ""
for t in texts:
m = re.search(r"Order\s*ID\s*:\s*(\d+)", t)
if m:
order_id = m.group(1)
break
if not order_id:
return None
anchor_idx = None
for i, t in enumerate(texts):
m = _TIKTOK_ANCHOR.match(t)
if m and (m.group(1) == "Receiver" or _PHONE_RE.search(t)):
anchor_idx = i
break
if anchor_idx is None:
return {"order_id": order_id, "recipient_name": "", "recipient_phone": "", "recipient_address": ""}
rest = _TIKTOK_ANCHOR.match(texts[anchor_idx]).group(2).strip()
phone = ""
name = rest
pm = _PHONE_RE.search(rest)
if pm: # 'To <이름> (+60)<전화>' 형식
phone = rest[pm.start():].strip()
name = rest[:pm.start()].strip()
addr_parts: list[str] = []
for t in texts[anchor_idx + 1:]:
if any(k in t for k in _TIKTOK_STOP):
break
if t.strip():
addr_parts.append(t.strip())
# 일부 배송사 라벨은 바코드/송장 숫자가 주소 영역에 섞인다. 10자리 이상
# 숫자열은 주소가 아니라 바코드이므로 제거(우편번호 5자리는 보존).
address = re.sub(r"\b\d{10,}\b", "", ", ".join(addr_parts))
address = _tidy(address)
return {"order_id": order_id, "recipient_name": name, "recipient_phone": phone, "recipient_address": address}
def _tidy(s: str) -> str:
"""중복 공백/콤마 정리."""
s = re.sub(r"\s+", " ", s)
s = re.sub(r"\s*,\s*", ", ", s)
s = re.sub(r"(,\s*){2,}", ", ", s)
return s.strip().strip(",").strip()
+773
View File
@@ -0,0 +1,773 @@
"""말레이시아 TikTok 출고관리 모듈 라우터.
- 경로: /dispatch
- 권한: 로그인 + `dispatch` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
- 데이터: DispatchStore (dispatch_db / PostgreSQL) 전용.
DISPATCH_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내.
- 업로드 원본은 DATA_DIR/dispatch/<날짜>/<배치slug>/ 에 저장, DB 엔 경로만 기록.
초보 물류 직원용 화면. 작업 기준 키는 Order ID / Package ID / Tracking ID /
Seller SKU / Quantity 이고, 받는 사람(이름/전화/주소)은 작업 카드 표시 + 취합
출고 엑셀 생성을 위해 박스 단위로 보관한다(개인정보).
"""
from __future__ import annotations
import io
import logging
import re
import shutil
import unicodedata
import zipfile
from pathlib import Path
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from app.timezone import now_kst, today_kst
from . import export, store
from .parser import ParseError, parse_export
from .pdf_parser import PdfParseError, extract_recipients
logger = logging.getLogger("dispatch.router")
router = APIRouter(prefix="/dispatch", tags=["dispatch"])
# 업로드 슬롯 (저장 파일명, 허용 확장자). 플랫폼마다 올리는 파일이 다르다.
# - 데이터 슬롯(필수): 자동 출고 리스트의 기준 엑셀.
# - 라벨 슬롯(선택): 실제 포장/라벨 원본 PDF(보관용).
# TikTok 의 01_Picking_List.pdf 는 쓰지 않으므로 업로드 받지 않는다.
_DATA_SLOTS: dict[str, tuple[str, tuple[str, ...]]] = {
store.PLATFORM_TIKTOK: ("03_TikTok_Order_Export.xlsx", (".xlsx",)),
store.PLATFORM_SHOPEE: ("Packing_List.Doorstep_Delivery.xlsx", (".xlsx",)),
store.PLATFORM_MANUAL: ("Manual_Order.xlsx", (".xlsx",)),
}
# 라벨(PDF) 슬롯이 없는 플랫폼(Manual)은 받는 사람 정보가 데이터 엑셀에 직접 있다.
_LABEL_SLOTS: dict[str, tuple[str, tuple[str, ...]]] = {
store.PLATFORM_TIKTOK: ("02_Shipping_Label_Packing_Slip.pdf", (".pdf",)),
store.PLATFORM_SHOPEE: ("Shopee_Seller_Centre.pdf", (".pdf",)),
}
# ────────────────────────────────────────────────────────────
# 공용 헬퍼
# ────────────────────────────────────────────────────────────
def _store(request: Request) -> Any:
return getattr(request.app.state, "dispatch_store", None)
def _data_dir(request: Request) -> Path:
return Path(getattr(request.app.state, "data_dir", Path("app/data")))
def _itemcode_name_map(request: Request) -> dict[str, str]:
"""itemcode_db {코드(대문자): 상품명}. 미설정/실패 시 빈 dict.
malaysia 모듈의 읽기 전용 리더(single_items + set_items)를 재사용한다.
"""
reader = getattr(request.app.state, "malaysia_itemcode", None)
if reader is None or not getattr(reader, "enabled", False):
return {}
try:
return reader.name_map()
except Exception: # noqa: BLE001 — 다운로드를 막지 않는다.
logger.exception("itemcode name_map 조회 실패")
return {}
def _itemcode_bom_and_sabangnet(request: Request) -> tuple[dict[str, Any], dict[str, str]]:
"""(세트 BOM map, item_code→사방넷코드 map). 미설정/실패 시 빈 dict."""
reader = getattr(request.app.state, "malaysia_itemcode", None)
if reader is None or not getattr(reader, "enabled", False):
return {}, {}
bom: dict[str, Any] = {}
sab: dict[str, str] = {}
try:
bom = reader.set_bom_map()
except Exception: # noqa: BLE001
logger.exception("itemcode set_bom_map 조회 실패")
try:
sab = reader.sabangnet_code_map()
except Exception: # noqa: BLE001
logger.exception("itemcode sabangnet_code_map 조회 실패")
return bom, sab
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, "dispatch"):
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": "말레이시아 배송 모듈이 아직 설정되지 않았습니다. "
"DISPATCH_DB_URL 환경변수를 설정하고 "
"scripts/sql/dispatch_db_init.sql 로 dispatch_db 를 초기화한 뒤 "
"컨테이너를 재기동하세요.",
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="dispatch"),
},
status_code=503,
)
def _guard(request: Request):
"""로그인+권한+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, "dispatch"):
return render_template(
request,
"denied.html",
{"reason": "말레이시아 배송 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)},
status_code=403,
)
st = _store(request)
if st is None:
return _render_config_needed(request, user)
return st, user
def _base_ctx(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_super": bool(user.get("is_super_admin")),
"nav_items": build_erp_nav(user, active="dispatch"),
"status_fields": store.STATUS_FIELDS,
"status_labels": store.STATUS_LABELS,
}
def _slugify(value: str) -> str:
"""배치명 → 폴더/파일 안전한 슬러그. 비면 'batch'."""
text = unicodedata.normalize("NFKC", value or "").strip()
text = re.sub(r"[^\w\-.가-힣]+", "_", text, flags=re.UNICODE)
text = text.strip("._")
return text or "batch"
def _save_upload(upload: UploadFile | None, *, dest_dir: Path, slot: tuple) -> str:
"""업로드 파일 1개 저장. 반환: 저장 경로(없으면 ""). 확장자 검증."""
filename, allowed_ext = slot
if upload is None or not (upload.filename or "").strip():
return ""
ext = Path(upload.filename).suffix.lower()
if ext not in allowed_ext:
raise HTTPException(
status_code=400,
detail=f"{filename}{', '.join(allowed_ext)} 형식이어야 합니다. (업로드: {upload.filename})",
)
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / filename
with dest.open("wb") as f:
shutil.copyfileobj(upload.file, f)
return str(dest)
# ════════════════════════════════════════════════════════════
# 배치 목록 (메인)
# ════════════════════════════════════════════════════════════
@router.get("/", response_class=HTMLResponse)
async def batches_index(request: Request) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
st, user = guard
batches = st.list_batches()
ctx = _base_ctx(request, user)
ctx.update(
{
"page_title": "말레이시아 배송 — 출고 배치",
"page_subtitle": "TikTok 일일 출고 배치 목록",
"batches": batches,
"batch_dates": sorted({b["dispatch_date"] for b in batches if b.get("dispatch_date")}),
"deducted_dates": st.list_deducted_dates(),
"active_tab": "batches",
}
)
return render_template(request, "dispatch/batches.html", ctx)
@router.get("/batches/new", response_class=HTMLResponse)
async def batch_new(request: Request) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
_st, user = guard
ctx = _base_ctx(request, user)
ctx.update(
{
"page_title": "말레이시아 배송 — 업로드",
"page_subtitle": "플랫폼 파일 업로드 → 출고 배치 자동 생성",
"today": today_kst().isoformat(),
"platforms": store.PLATFORMS,
"active_tab": "new",
}
)
return render_template(request, "dispatch/new.html", ctx)
@router.post("/batches")
async def batch_create(
request: Request,
dispatch_date: str = Form(...),
platform: str = Form("TikTok"),
batch_name: str = Form(""),
label_pdf: UploadFile | None = File(None),
data_xlsx: UploadFile | None = File(None),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
"""파일 업로드 + 배치 생성 + 엑셀 파싱 + 박스 저장.
플랫폼별 슬롯:
- TikTok: 데이터=03_TikTok_Order_Export.xlsx(필수), 라벨=Shipping Label PDF(선택)
- Shopee: 데이터=Packing List.Doorstep Delivery.xlsx(필수), 라벨=Shopee Seller Centre.pdf(선택)
같은 날짜/플랫폼이라도 새 배치로 생성한다(덮어쓰지 않음).
"""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
platform = (platform or store.PLATFORM_TIKTOK).strip()
if platform not in _DATA_SLOTS:
raise HTTPException(status_code=400, detail=f"지원하지 않는 플랫폼입니다: {platform}")
data_slot = _DATA_SLOTS[platform]
label_slot = _LABEL_SLOTS.get(platform) # Manual 은 라벨 없음(None)
if data_xlsx is None or not (data_xlsx.filename or "").strip():
raise HTTPException(
status_code=400,
detail=f"{data_slot[0]} 는 필수입니다(자동 출고 리스트 기준 데이터).",
)
# 저장 폴더: DATA_DIR/dispatch/<날짜>/<배치slug>_<HHMMSS>/
name = (batch_name or "").strip() or f"{platform} 출고"
stamp = now_kst().strftime("%H%M%S")
dest_dir = _data_dir(request) / "dispatch" / dispatch_date.strip() / f"{_slugify(name)}_{stamp}"
label_path = _save_upload(label_pdf, dest_dir=dest_dir, slot=label_slot) if label_slot else ""
order_path = _save_upload(data_xlsx, dest_dir=dest_dir, slot=data_slot)
# 엑셀 파싱 — 실패 시 사용자에게 한국어 메시지 그대로 보여준다.
try:
parsed = parse_export(order_path, platform=platform)
except ParseError as exc:
raise HTTPException(status_code=400, detail=str(exc))
parcels = parsed["parcels"]
if not parcels:
raise HTTPException(
status_code=400,
detail="엑셀에서 출고할 박스를 찾지 못했습니다. 컬럼/데이터를 확인하세요.",
)
# 라벨 PDF 가 있으면 받는 사람(이름/전화/주소)을 추출해 Order ID 로 박스에 채운다.
# 이름/주소는 xlsx 에 없거나 가려져 있어 PDF 가 출처. PDF 없거나 실패 시 빈 값.
if label_path:
try:
recipients = extract_recipients(label_path, platform)
except PdfParseError as exc:
logger.warning("라벨 PDF 파싱 실패 — PII 없이 진행: %s", exc)
recipients = {}
# PDF 가 받는 사람 정보의 정본(authoritative). xlsx 의 가려진 이름/주소는
# 신뢰하지 않으므로 PDF 값이 있으면 덮어쓴다.
for p in parcels:
rec = recipients.get((p.get("order_id") or "").strip())
if not rec:
continue
for field in ("recipient_name", "recipient_phone", "recipient_address"):
if rec.get(field):
p[field] = rec[field]
batch = st.create_batch(
platform=platform,
dispatch_date=dispatch_date,
batch_name=name,
picking_pdf_path="",
label_pdf_path=label_path,
order_export_path=order_path,
)
st.add_parcels(batch_id=batch["id"], parcels=parcels)
return RedirectResponse(url=f"/dispatch/batches/{batch['id']}", status_code=303)
@router.post("/batches/{batch_id:int}/delete")
async def batch_delete(
request: Request, batch_id: int, user: dict[str, Any] = Depends(_require_user)
) -> RedirectResponse:
"""배치 삭제 — 슈퍼 관리자 전용(박스/상품/로그 CASCADE)."""
if not bool(user.get("is_super_admin")):
raise HTTPException(status_code=403, detail="배치 삭제는 슈퍼 관리자만 가능합니다.")
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
try:
st.delete_batch(batch_id=batch_id)
except KeyError:
raise HTTPException(status_code=404, detail="배치를 찾을 수 없습니다.")
return RedirectResponse(url="/dispatch/", status_code=303)
@router.get("/batches/{batch_id:int}/download")
async def batch_download(
request: Request, batch_id: int, user: dict[str, Any] = Depends(_require_user)
) -> Response:
"""배치 다운로드: 업로드 원본(라벨 PDF + 데이터 엑셀) + 취합 출고 엑셀을 zip 으로 묶음."""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
batch = st.get_batch(batch_id=batch_id)
if batch is None:
raise HTTPException(status_code=404, detail="배치를 찾을 수 없습니다.")
# 저장된 경로 중 실제로 존재하는 파일만 묶는다. 같은 파일명 충돌 시 번호 부여.
paths = [
batch.get("picking_pdf_path"),
batch.get("label_pdf_path"),
batch.get("order_export_path"),
]
buf = io.BytesIO()
used: set[str] = set()
count = 0
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for raw in paths:
p = Path(raw) if raw else None
if not p or not p.is_file():
continue
arcname = p.name
n = 1
while arcname in used:
arcname = f"{p.stem}_{n}{p.suffix}"
n += 1
used.add(arcname)
zf.write(p, arcname=arcname)
count += 1
# 취합 출고 엑셀(발송날짜/고객/주소/상품/택배 등)을 생성해 함께 넣는다.
# 상품이름은 아이템코드로 itemcode_db 에서 조회해 채운다.
parcels = st.list_parcels(batch_id=batch_id)
name_map = _itemcode_name_map(request)
xlsx_name = export.export_filename(
dispatch_date=batch.get("dispatch_date", ""),
platform=batch.get("platform", ""),
)
try:
xlsx_bytes = export.build_workbook_bytes(
batch=batch, parcels=parcels, name_map=name_map
)
zf.writestr(xlsx_name, xlsx_bytes)
count += 1
except Exception: # noqa: BLE001 — 엑셀 생성 실패해도 원본 다운로드는 살린다.
logger.exception("출고 엑셀 생성 실패 batch_id=%s", batch_id)
if count == 0:
raise HTTPException(status_code=404, detail="다운로드할 파일이 없습니다.")
slug = _slugify(batch.get("batch_name") or "dispatch")
fname = f"{batch.get('dispatch_date', '')}_{slug}.zip".lstrip("_")
headers = {
"Content-Disposition": (
f"attachment; filename=\"dispatch_{batch_id}.zip\"; "
f"filename*=UTF-8''{quote(fname)}"
)
}
return Response(
content=buf.getvalue(), media_type="application/zip", headers=headers
)
@router.get("/stock-export")
async def stock_export(
request: Request, date: str, user: dict[str, Any] = Depends(_require_user)
) -> Response:
"""선택한 날짜의 전 배치 출고 수량을 낱개로 분해해 사방넷 재고 업로드 xlsx 다운로드.
콤보(세트)는 BOM 으로 낱개 분해, _N 배수 반영, MD-(뚜껑) 제외.
A=사방넷코드 / B=수량 / C=불용수량(0) / D=아이템코드.
"""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
d = (date or "").strip()
if not d:
raise HTTPException(status_code=400, detail="날짜가 필요합니다.")
rows = st.stock_aggregate_for_date(dispatch_date=d)
if not rows:
raise HTTPException(status_code=404, detail="해당 날짜의 출고 데이터가 없습니다.")
set_bom, sabangnet_map = _itemcode_bom_and_sabangnet(request)
# 세트(MY-)인데 BOM 을 못 찾으면 낱개 분해가 안 된 채 파일이 만들어진다.
# 조용히 잘못된 파일을 주지 말고, 어떤 세트가 분해 안 되는지 명확히 알린다.
missing_sets = sorted({
b for b in (
export.split_sku(r.get("seller_sku", ""))[0].strip().upper() for r in rows
)
if b.startswith(export._SET_PREFIX) and b not in set_bom
})
if missing_sets:
reader = getattr(request.app.state, "malaysia_itemcode", None)
hint = getattr(reader, "last_error", "") or getattr(reader, "reason", "")
detail = (
"세트 BOM 을 찾지 못해 낱개로 분해할 수 없습니다: "
+ ", ".join(missing_sets)
+ ". itemcode_db 의 set_components 에 해당 세트 BOM 이 등록돼 있는지, "
"읽기 전용 역할(itemcode_ro)에 set_components SELECT 권한이 있는지 확인하세요."
)
if hint:
detail += f" (itemcode 리더 오류: {hint})"
raise HTTPException(status_code=422, detail=detail)
# 사방넷 코드 맵이 통째로 비어 있으면 A열(상품코드[필수])이 전부 공란이 된다.
# 보통 itemcode_ro 의 single_items/set_items SELECT 권한 누락이 원인.
reader = getattr(request.app.state, "malaysia_itemcode", None)
if getattr(reader, "enabled", False) and not sabangnet_map:
hint = getattr(reader, "last_error", "") or getattr(reader, "reason", "")
detail = (
"사방넷 코드 맵이 비어 있어 상품코드[필수] 열을 채울 수 없습니다. "
"itemcode_db 의 single_items/set_items 에 sabangnet_code 가 등록돼 있는지, "
"읽기 전용 역할(itemcode_ro)에 두 테이블 SELECT 권한이 있는지 확인하세요."
)
if hint:
detail += f" (itemcode 리더 오류: {hint})"
raise HTTPException(status_code=422, detail=detail)
xlsx_bytes = export.build_stock_xlsx(rows=rows, set_bom=set_bom, sabangnet_map=sabangnet_map)
fname = export.stock_filename(d)
headers = {
"Content-Disposition": (
f"attachment; filename=\"stock_{d}.xlsx\"; "
f"filename*=UTF-8''{quote(fname)}"
)
}
return Response(
content=xlsx_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers=headers,
)
@router.post("/stock-deduct")
async def stock_deduct(
request: Request,
date: str = Form(...),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
"""선택 날짜의 출고를 말레이시아 재고관리에 OUT 으로 반영(1회성).
- 낱개(MT/MX/MZ)는 낱개 OUT, 콤보(MY-)는 세트 OUT(BOM 으로 낱개 분해).
- _N 배수 반영, MD-(뚜껑) 제외. movement_date = 해당 날짜.
- dispatch_stock_deductions 로 날짜당 1회만 — 재실행 시 재고 이중 차감 방지.
"""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
d = (date or "").strip()
if not d:
raise HTTPException(status_code=400, detail="날짜가 필요합니다.")
if st.deduction_exists(dispatch_date=d):
raise HTTPException(status_code=409, detail=f"{d} 는 이미 재고 차감되었습니다.")
ms = getattr(request.app.state, "malaysia_store", None)
if ms is None:
raise HTTPException(status_code=503, detail="말레이시아 재고관리(MALAYSIA_STOCK_DB_URL) 미설정")
# 낱개/콤보 분리(_N 배수, MD- 제외)
rows = st.stock_aggregate_for_date(dispatch_date=d)
singles, combos = export.split_singles_combos(rows)
if not singles and not combos:
raise HTTPException(status_code=404, detail="해당 날짜의 출고 데이터가 없습니다.")
# 창고 결정(첫 활성 창고)
warehouses = ms.list_warehouses()
if not warehouses:
raise HTTPException(status_code=400, detail="등록된 창고가 없습니다.")
wh = warehouses[0]["warehouse_code"]
# 콤보 BOM 사전검증(누락 시 아무 것도 쓰지 않고 중단)
bom_map, _sab = _itemcode_bom_and_sabangnet(request)
missing = [c for c in combos if c not in bom_map]
if missing:
# 'BOM 누락'을 원인별로 쪼개 정확히 알린다. 그동안 이 메시지가
# (a)미등록 (b)구성품없음 (c)권한/조회실패 를 한 덩어리로 가려서
# "고쳐도 또 난다"가 반복됐다.
reader = getattr(request.app.state, "malaysia_itemcode", None)
registered: set[str] = set()
if reader is not None and getattr(reader, "enabled", False):
try:
registered = reader.set_codes_present()
except Exception: # noqa: BLE001
logger.exception("set_codes_present 조회 실패")
query_failed = bool(getattr(reader, "last_error", "")) if reader else False
if query_failed:
# set_components 조회 자체가 실패 — 보통 itemcode_ro 권한 문제.
raise HTTPException(
status_code=400,
detail=(
"itemcode_db 의 세트구성(set_components) 조회에 실패해 콤보를 분해할 수 "
"없습니다. 읽기 전용 역할(itemcode_ro)에 set_components SELECT 권한이 "
f"있는지 확인하세요. (상세: {reader.last_error})"
),
)
no_components = sorted(c for c in missing if c in registered)
not_registered = sorted(c for c in missing if c not in registered)
parts: list[str] = []
if not_registered:
parts.append(
"세트구성 미등록(itemcode_db.set_components 에 코드 자체가 없음): "
+ ", ".join(not_registered)
)
if no_components:
parts.append(
"세트는 등록됐으나 유효한 낱개(MT-/MX-/MZ-) 구성품이 없습니다"
"(구성품 코드·수량 확인): " + ", ".join(no_components)
)
raise HTTPException(status_code=400, detail="콤보 BOM 문제 — " + " / ".join(parts))
# 마커 선점(레이스/재실행 차단). 충돌 시 이미 차감됨.
try:
st.record_deduction(
dispatch_date=d, warehouse_code=wh,
single_lines=len(singles), combo_lines=len(combos),
worker_name=user.get("email", ""),
)
except Exception: # noqa: BLE001 — unique 위반 등 = 이미 차감
raise HTTPException(status_code=409, detail=f"{d} 는 이미 재고 차감되었습니다.")
# 실제 OUT 반영. 실패해도 마커는 남겨 재고 이중 차감을 막는다(원인은 로그).
memo = f"배송 출고 {d}"
try:
for code, qty in combos.items():
ms.create_set_out(
movement_date=d, warehouse_code=wh, set_code=code, set_qty=qty,
bom_map=bom_map, ref_type="dispatch", ref_no=d, memo=memo,
created_by=user.get("email", ""),
)
for code, qty in singles.items():
ms.create_movement(
movement_date=d, warehouse_code=wh, item_code=code,
movement_type="OUT", qty=qty, ref_type="dispatch", ref_no=d,
memo=memo, created_by=user.get("email", ""),
)
except Exception as exc: # noqa: BLE001
logger.exception("재고 차감 중 오류 date=%s", d)
raise HTTPException(
status_code=500,
detail=f"일부 반영 중 오류가 발생했습니다(재차감 방지를 위해 차감완료로 표시됨). 재고관리 이동이력을 확인하세요: {type(exc).__name__}",
)
return JSONResponse({
"ok": True, "date": d, "warehouse": wh,
"singles": len(singles), "combos": len(combos),
})
# ════════════════════════════════════════════════════════════
# 출고 작업 리스트 (1박스 = 1카드)
# ════════════════════════════════════════════════════════════
@router.get("/batches/{batch_id:int}", response_class=HTMLResponse)
async def batch_detail(request: Request, batch_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
st, user = guard
batch = st.get_batch(batch_id=batch_id)
if batch is None:
return render_template(
request, "denied.html",
{"reason": "출고 배치를 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
parcels = st.list_parcels(batch_id=batch_id)
done_count = sum(1 for p in parcels if all(p[f] for f in store.STATUS_FIELDS))
ctx = _base_ctx(request, user)
ctx.update(
{
"page_title": f"출고 작업 — {batch['batch_name']}",
"page_subtitle": f"{batch['dispatch_date']} · {batch['platform']} · 박스 {len(parcels)}",
"batch": batch,
"parcels": parcels,
"done_count": done_count,
"active_tab": "work",
}
)
return render_template(request, "dispatch/detail.html", ctx)
@router.get("/batches/{batch_id:int}/picking", response_class=HTMLResponse)
async def batch_picking(request: Request, batch_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
st, user = guard
batch = st.get_batch(batch_id=batch_id)
if batch is None:
return render_template(
request, "denied.html",
{"reason": "출고 배치를 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
summary = st.sku_summary(batch_id=batch_id)
ctx = _base_ctx(request, user)
ctx.update(
{
"page_title": f"피킹 요약 — {batch['batch_name']}",
"page_subtitle": f"{batch['dispatch_date']} · SKU별 총 수량",
"batch": batch,
"summary": summary,
"total_qty": sum(r["total_qty"] for r in summary),
"active_tab": "picking",
}
)
return render_template(request, "dispatch/picking.html", ctx)
@router.get("/batches/{batch_id:int}/handover", response_class=HTMLResponse)
async def batch_handover(request: Request, batch_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
st, user = guard
batch = st.get_batch(batch_id=batch_id)
if batch is None:
return render_template(
request, "denied.html",
{"reason": "출고 배치를 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
summary = st.handover_summary(batch_id=batch_id)
ctx = _base_ctx(request, user)
ctx.update(
{
"page_title": f"Kagayaku 전달 — {batch['batch_name']}",
"page_subtitle": f"{batch['dispatch_date']} · {batch['platform']}",
"batch": batch,
"handover": summary,
"active_tab": "handover",
}
)
return render_template(request, "dispatch/handover.html", ctx)
# ════════════════════════════════════════════════════════════
# 상태 토글 (AJAX) + JSON API
# ════════════════════════════════════════════════════════════
@router.post("/parcels/{parcel_id:int}/toggle")
async def parcel_toggle(
request: Request,
parcel_id: int,
field: str = Form(...),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
"""박스 작업 상태 1개 토글 → 즉시 DB 저장 + 로그. JSON 반환(버튼 색 갱신용)."""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
try:
result = st.toggle_status(
parcel_id=parcel_id, field=field, worker_name=user.get("email", "")
)
except KeyError:
raise HTTPException(status_code=404, detail="박스를 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"ok": True, **result})
@router.post("/batches/{batch_id:int}/bulk")
async def parcels_bulk(
request: Request,
batch_id: int,
field: str = Form(...),
value: str = Form("true"),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
"""배치 내 모든 박스의 작업 상태 1개를 value(완료/해제)로 일괄 설정. JSON 반환."""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
target = str(value).strip().lower() in ("true", "1", "on", "yes")
try:
count = st.bulk_set_status(
batch_id=batch_id, field=field, value=target, worker_name=user.get("email", "")
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"ok": True, "field": field, "value": target, "count": count})
@router.get("/api/batches/{batch_id:int}/parcels")
async def api_parcels(
request: Request, batch_id: int, _: dict[str, Any] = Depends(_require_user)
) -> JSONResponse:
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
return JSONResponse({"parcels": st.list_parcels(batch_id=batch_id)})
@router.get("/api/search")
async def api_search(
request: Request, q: str = "", _: dict[str, Any] = Depends(_require_user)
) -> JSONResponse:
"""주문번호/패키지번호/송장번호(부분 일치)로 박스 검색 → 받는 사람/배치 반환."""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
query = (q or "").strip()
if len(query) < 2:
return JSONResponse({"query": query, "results": []})
results = st.search_parcels(query=query)
# 송장 조회 딥링크는 배송사 맵(store)에서 만든다 — 프런트엔 맵이 없다.
for r in results:
r["tracking_url"] = store.courier_tracking_url(
r.get("tracking_id", ""), r.get("shipping_provider", "")
)
return JSONResponse({"query": query, "results": results})
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "module": "dispatch"}
+392
View File
@@ -0,0 +1,392 @@
"""말레이시아 TikTok 출고관리 모듈 — 상수 및 순수 계산/검증 헬퍼.
- 데이터 저장은 dispatch_db(PostgreSQL) 전용이다(`db.py`).
- 엑셀 읽기는 `parser.py`(openpyxl)가 담당하고, 여기에는 DB/파일 의존이 없는
순수 함수(컬럼 정규화, 박스 묶기, SKU 합산)만 둔다(테스트 용이).
핵심 업무 규칙:
- 03_TikTok_Order_Export.xlsx 가 자동 출고 리스트의 기준 데이터다.
- 엑셀 1줄 ≠ 1박스. 1박스 묶음 기준은 아래 우선순위로 정한다:
1순위 package_id → 2순위 tracking_id → 3순위 order_id
- 같은 박스 안 같은 SKU 는 수량을 합산한다.
- 고객 이름/주소/전화 등 개인정보는 절대 보관하지 않는다(허용 컬럼만 사용).
"""
from __future__ import annotations
import re
from functools import lru_cache
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import quote
# ── 작업 상태 필드(토글 대상) ──
# 순서 = 작업 진행 순서. label_ko/label_en 은 버튼 표기.
# 상품은 미리 제작·박스 포장까지 끝난 상태로 들어오므로 product_ready/packed
# 단계는 화면에서 쓰지 않는다(DB 컬럼은 보존 — 과거 데이터/확장 대비).
# 실제 작업: 송장 라벨 부착 → Kagayaku 전달 → 택배 스캔 확인.
STATUS_FIELDS: tuple[str, ...] = (
"label_attached",
"handed_to_kagayaku",
)
STATUS_LABELS: dict[str, dict[str, str]] = {
"product_ready": {"ko": "상품준비", "en": "Product Ready"},
"packed": {"ko": "포장완료", "en": "Packed"},
"label_attached": {"ko": "라벨부착", "en": "Label Attached"},
"handed_to_kagayaku": {"ko": "Kagayaku 전달", "en": "Handed to Kagayaku"},
"courier_scanned": {"ko": "택배스캔 확인", "en": "Courier Scanned"},
}
# ── 플랫폼 ──
# 주문처(엑셀 '주문처' 컬럼)이자 업로드 슬롯/파서 선택의 기준값.
PLATFORM_TIKTOK = "TikTok"
PLATFORM_SHOPEE = "Shopee"
PLATFORM_MANUAL = "Manual"
PLATFORMS: tuple[str, ...] = (PLATFORM_TIKTOK, PLATFORM_SHOPEE, PLATFORM_MANUAL)
# ── 엑셀 컬럼명(앞뒤 공백 제거 후, 소문자 비교). 표준키 → 가능한 헤더들 ──
# TikTok(03_TikTok_Order_Export.xlsx)·Shopee(Packing List.Doorstep Delivery.xlsx)
# 두 포맷의 헤더를 한 테이블에 모은다(매칭되는 것만 사용). 헤더가 다르면
# 해당 표준키 줄에 별칭 1개만 추가하면 된다.
# 받는 사람(recipient_*) 정보는 작업 카드 표시 + 출고 엑셀 생성을 위해 보관한다.
COLUMN_ALIASES: dict[str, tuple[str, ...]] = {
"order_id": ("order id", "order_id", "order sn", "order_sn", "ordersn", "order no.", "order number", "주문번호"),
"package_id": ("package id", "package_id", "package number", "패키지번호"),
"tracking_id": ("tracking id", "tracking_id", "tracking number", "tracking_number", "tracking no", "tracking no.", "awb", "awb no.", "송장번호"),
"shipping_provider": ("shipping provider name", "shipping provider", "shipping_provider_name",
"shipping option", "courier", "carrier", "logistics", "배송사", "택배사"),
"seller_sku": ("seller sku", "seller_sku", "sku", "sku reference no.", "sku reference no",
"parent sku reference no.", "판매자 sku", "상품코드"),
"product_name": ("product name", "product_name", "item name", "product", "상품명"),
"quantity": ("quantity", "qty", "수량", "수량(개)"),
# 받는 사람(이름/전화/주소)은 xlsx 에서 매핑하지 않는다 — xlsx 값은 가려져 있어
# 신뢰할 수 없다. PII 는 라벨 PDF 에서만 가져온다(pdf_parser, Order ID 조인).
}
# 사용자에게 보여줄 컬럼 이름(누락 안내 메시지용). 비교키는 소문자라 별도 표기.
COLUMN_DISPLAY: dict[str, str] = {
"order_id": "Order ID",
"package_id": "Package ID",
"tracking_id": "Tracking ID",
"shipping_provider": "Shipping Provider Name",
"seller_sku": "Seller SKU",
"product_name": "Product Name",
"quantity": "Quantity",
"recipient_name": "Recipient",
"recipient_phone": "Phone",
"recipient_address": "Address",
}
# 박스 단위로 보존하는 받는 사람 필드(같은 박스의 첫 비어있지 않은 값 사용).
RECIPIENT_FIELDS: tuple[str, ...] = ("recipient_name", "recipient_phone", "recipient_address")
# Shopee Packing List 의 상품 정보 컬럼(헤더 정규화 후 비교).
PRODUCT_INFO_ALIASES: tuple[str, ...] = ("product_info", "product info", "상품정보")
# Shopee product_info 한 셀에 여러 상품이 [1]…[2]… 로 들어온다. 각 상품은
# "Key:Value" 들이 ';' 로 구분된다(예: Product Name:…; Variation Name:…; Price:…;
# Quantity:1; SKU Reference No.: MT-0320_3;).
_SHOPEE_ITEM_SPLIT = re.compile(r"\[\d+\]\s*")
_SHOPEE_KEY_MAP: dict[str, str] = {
"product name": "product_name",
"variation name": "variation",
"quantity": "quantity",
"sku reference no": "seller_sku", # 끝의 '.' 은 비교 전에 제거
}
def parse_shopee_product_info(blob: Any) -> list[dict[str, Any]]:
"""Shopee product_info 셀 → 상품 리스트.
반환 각 항목: {seller_sku, product_name, variation, quantity}.
상품명 안에 ';' 이 들어가면 필드 분리가 깨질 수 있으나 Shopee 상품명에는
드물어 허용한다(필요 시 별도 처리).
"""
s = clean_text(blob)
if not s:
return []
items: list[dict[str, Any]] = []
for chunk in _SHOPEE_ITEM_SPLIT.split(s):
chunk = chunk.strip()
if not chunk:
continue
fields: dict[str, str] = {}
for part in chunk.split(";"):
if ":" not in part:
continue
raw_key, _, value = part.partition(":")
key = raw_key.strip().lower().rstrip(".").strip()
std = _SHOPEE_KEY_MAP.get(key)
if std:
fields[std] = value.strip()
sku = fields.get("seller_sku", "")
if not sku and not fields.get("product_name"):
continue # 식별 불가한 빈 항목 스킵
items.append(
{
"seller_sku": sku,
"product_name": fields.get("product_name", ""),
"variation": fields.get("variation", ""),
"quantity": normalize_quantity(fields.get("quantity")),
}
)
return items
def find_column(headers: Iterable[Any], aliases: tuple[str, ...]) -> int | None:
"""헤더에서 별칭에 맞는 첫 열 인덱스. 없으면 None."""
for idx, h in enumerate(headers):
if normalize_header(h) in aliases:
return idx
return None
# 박스 묶음 기준 우선순위. 앞에서부터 비어있지 않은 첫 값으로 묶는다.
BOX_KEY_PRIORITY: tuple[str, ...] = ("package_id", "tracking_id", "order_id")
# 파싱에 최소한 필요한 표준키. (개인정보는 불필요)
# - SKU/수량이 없으면 포장할 상품을 못 만든다 → seller_sku 필수.
# - 박스 묶음을 위해 묶음키 3개 중 최소 하나는 있어야 한다.
REQUIRED_COLUMNS: tuple[str, ...] = ("seller_sku",)
def clean_text(value: Any) -> str:
"""셀 값 → 안전한 문자열. None/NaN 은 빈 문자열. 양끝 공백 제거.
숫자는 과학적 표기/불필요한 .0 없이 보존한다(예: 송장번호가 float 로 읽혀도
'12345678901' 로 보존). Tracking ID 보존 요구사항을 만족한다.
"""
if value is None:
return ""
# pandas/openpyxl NaN(float('nan')) 방어
if isinstance(value, float):
if value != value: # NaN
return ""
if value.is_integer():
return str(int(value))
return repr(value)
if isinstance(value, int):
return str(value)
s = str(value).strip()
if s.lower() in ("nan", "none"):
return ""
return s
def normalize_header(value: Any) -> str:
"""헤더 셀 → 비교용 키(공백 제거 + 소문자)."""
return clean_text(value).lower()
def resolve_columns(headers: Iterable[Any]) -> dict[str, int]:
"""헤더 행 → {표준키: 열 인덱스}. 매핑 안 되는 열은 무시(개인정보 포함)."""
norm = [normalize_header(h) for h in headers]
mapping: dict[str, int] = {}
for std_key, aliases in COLUMN_ALIASES.items():
for idx, h in enumerate(norm):
if h in aliases:
mapping[std_key] = idx
break
return mapping
def missing_required(mapping: dict[str, int]) -> list[str]:
"""필수 컬럼 누락 목록(원문 헤더 형태로). 묶음키 전무도 누락으로 본다."""
missing: list[str] = []
for key in REQUIRED_COLUMNS:
if key not in mapping:
missing.append(COLUMN_DISPLAY[key])
if not any(k in mapping for k in BOX_KEY_PRIORITY):
missing.append("Package ID / Tracking ID / Order ID (최소 1개)")
return missing
def box_key(row: dict[str, str]) -> str:
"""박스 묶음 키 — package_id > tracking_id > order_id 우선순위."""
for key in BOX_KEY_PRIORITY:
v = (row.get(key) or "").strip()
if v:
return f"{key}:{v}"
return ""
def group_parcels(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
"""정규화된 행 리스트 → 박스(parcel) 리스트.
rows 각 항목 키: order_id/package_id/tracking_id/shipping_provider/
seller_sku/product_name/quantity(이미 문자열/int 정리됨).
반환(등장 순서 유지, seq 1..N 부여):
[{seq, order_id, package_id, tracking_id, shipping_provider,
items: [{seller_sku, product_name, quantity}, ...]}, ...]
같은 박스 안 같은 SKU 는 수량 합산. 묶음키가 전혀 없는 행은 버린다.
"""
boxes: dict[str, dict[str, Any]] = {}
for row in rows:
key = box_key(row)
if not key:
continue
box = boxes.get(key)
if box is None:
box = {
"key": key,
"order_id": (row.get("order_id") or "").strip(),
"package_id": (row.get("package_id") or "").strip(),
"tracking_id": (row.get("tracking_id") or "").strip(),
"shipping_provider": (row.get("shipping_provider") or "").strip(),
"recipient_name": (row.get("recipient_name") or "").strip(),
"recipient_phone": (row.get("recipient_phone") or "").strip(),
"recipient_address": (row.get("recipient_address") or "").strip(),
"_items": {}, # sku -> {product_name, quantity}
}
boxes[key] = box
else:
# 같은 박스인데 식별값이 빈 경우 뒤늦게 채운다(누락 보강).
for field in ("order_id", "package_id", "tracking_id", "shipping_provider",
"recipient_name", "recipient_phone", "recipient_address"):
if not box[field] and (row.get(field) or "").strip():
box[field] = (row.get(field) or "").strip()
sku = (row.get("seller_sku") or "").strip()
if not sku:
continue
qty = normalize_quantity(row.get("quantity"))
item = box["_items"].get(sku)
if item is None:
box["_items"][sku] = {
"seller_sku": sku,
"product_name": (row.get("product_name") or "").strip(),
"quantity": qty,
}
else:
item["quantity"] += qty
if not item["product_name"] and (row.get("product_name") or "").strip():
item["product_name"] = (row.get("product_name") or "").strip()
parcels: list[dict[str, Any]] = []
for seq, box in enumerate(boxes.values(), start=1):
items = sorted(box["_items"].values(), key=lambda it: it["seller_sku"])
parcels.append(
{
"seq": seq,
"order_id": box["order_id"],
"package_id": box["package_id"],
"tracking_id": box["tracking_id"],
"shipping_provider": box["shipping_provider"],
"recipient_name": box["recipient_name"],
"recipient_phone": box["recipient_phone"],
"recipient_address": box["recipient_address"],
"items": items,
}
)
return parcels
def normalize_quantity(value: Any) -> int:
"""수량 정규화. 비어있으면 1. 음수/0/파싱불가도 1 로 보정(요구사항 11)."""
s = clean_text(value)
if s == "":
return 1
try:
n = int(float(s))
except (TypeError, ValueError):
return 1
return n if n > 0 else 1
# ── 배송사 로고 ──
# 배송사명(부분 일치, 소문자) → 슬러그. 슬러그 이름의 이미지 파일이 실제로
# 있으면 이미지로 렌더하고, 없으면 텍스트 배지로 폴백한다(깨진 이미지 방지).
# 새 배송사 로고 추가: 파일 1개 + 키워드 1줄.
# 1) app/static/dispatch/courier/<슬러그>.png (또는 .svg) 추가
# 2) 아래 _COURIER_KEYWORDS 에 (배송사명키워드, 슬러그) 추가
_COURIER_KEYWORDS: tuple[tuple[str, str], ...] = (
("ninja", "ninjavan"),
("j&t", "jnt"),
("jnt", "jnt"),
("flash", "flash"),
("city", "citylink"),
("pos laju", "poslaju"),
("poslaju", "poslaju"),
("gdex", "gdex"),
("gd express", "gdex"),
("shopee", "spx"),
("spx", "spx"),
)
# 로고 파일 디렉토리(app/static/dispatch/courier). png 를 svg 보다 우선한다.
_COURIER_DIR = Path(__file__).resolve().parents[2] / "static" / "dispatch" / "courier"
_COURIER_EXTS: tuple[str, ...] = (".png", ".svg")
def courier_slug(provider: Any) -> str:
"""배송사명 → 로고 슬러그(부분 일치). 매칭 없으면 ""."""
name = clean_text(provider).lower()
if not name:
return ""
for keyword, slug in _COURIER_KEYWORDS:
if keyword in name:
return slug
return ""
@lru_cache(maxsize=128)
def _logo_url_for_slug(slug: str) -> str:
"""슬러그에 해당하는 실제 로고 파일 URL(png 우선). 없으면 ""."""
if not slug:
return ""
for ext in _COURIER_EXTS:
if (_COURIER_DIR / f"{slug}{ext}").exists():
return f"/static/dispatch/courier/{slug}{ext}"
return ""
def courier_logo_url(provider: Any) -> str:
"""배송사명 → 로고 이미지 URL. 파일 없으면 ""(템플릿이 텍스트로 폴백)."""
return _logo_url_for_slug(courier_slug(provider))
# ── 배송사별 송장 조회 딥링크 ──
# {t} 자리에 송장번호를 넣어 조회 페이지로 바로 이동(자동 입력 효과).
# ⚠️ 배송사 사이트 URL 은 바뀔 수 있다. 안 열리면 여기만 고치면 된다.
# 매핑 없는 배송사는 링크 없이 텍스트로 표시(courier_tracking_url 가 "" 반환).
_COURIER_TRACK_URL: dict[str, str] = {
"ninjavan": "https://www.ninjavan.co/en-my/tracking?id={t}",
# J&T MY 는 경로(path)에 송장번호를 붙이면 자동 조회된다(쿼리 파라미터는 무시).
"jnt": "https://www.jtexpress.my/tracking/{t}",
"flash": "https://www.flashexpress.my/fle/tracking?se={t}",
"citylink": "https://www.citylinkexpress.com/tracking-result/?track_no={t}",
"poslaju": "https://track.pos.com.my/postal-services/quick-access/?track-trace&trackingNo03={t}",
"gdex": "https://web.gdexpress.com/official/web/ConsignmentSearch.html?capcheck=true&input_search={t}",
"spx": "https://spx.com.my/track?tracking_number={t}",
}
def courier_tracking_url(tracking: Any, provider: Any = "") -> str:
"""송장번호 + 배송사명 → 조회 딥링크. 매핑/번호 없으면 ""."""
t = clean_text(tracking)
tmpl = _COURIER_TRACK_URL.get(courier_slug(provider))
if not t or not tmpl:
return ""
return tmpl.replace("{t}", quote(t, safe=""))
def sku_summary(parcels: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""전체 박스 기준 SKU별 총 수량(피킹 요약). seller_sku 오름차순."""
totals: dict[str, dict[str, Any]] = {}
for p in parcels:
for it in p.get("items", []):
sku = it["seller_sku"]
row = totals.get(sku)
if row is None:
totals[sku] = {
"seller_sku": sku,
"product_name": it.get("product_name", ""),
"total_qty": int(it["quantity"]),
}
else:
row["total_qty"] += int(it["quantity"])
if not row["product_name"] and it.get("product_name"):
row["product_name"] = it["product_name"]
return sorted(totals.values(), key=lambda r: r["seller_sku"])
@@ -0,0 +1,84 @@
{# 말레이시아 배송 공용 상단 바: 목록/업로드 + (배치 진입 시) 작업/피킹/전달 서브탭
+ 한글/영문 토글(localStorage 저장, 전 페이지 공통). #}
<div class="erp-page-actions" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<a class="erp-btn {% if active_tab=='batches' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/dispatch/"
data-ko="출고 배치" data-en="Batches">출고 배치</a>
<a class="erp-btn {% if active_tab=='new' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/dispatch/batches/new"
data-ko=" 새 업로드" data-en="+ New Upload"> 새 업로드</a>
{% if active_tab=='batches' %}
{# 받는 사람 찾기: 배치 목록 페이지에서만. 마크업/스타일/스크립트는 batches.html. #}
<div class="dsp-search" id="dsp-search">
<div class="dsp-search-box">
<span class="dsp-q-ico">🔎</span>
<input type="text" id="dsp-q" autocomplete="off"
data-ko-ph="이름 · 주문번호 · 패키지번호 · 송장번호로 받는 사람 찾기"
data-en-ph="Find recipient by name / order / package / tracking no."
placeholder="이름 · 주문번호 · 패키지번호 · 송장번호로 받는 사람 찾기" />
<button type="button" class="dsp-q-clear" id="dsp-q-clear" aria-label="clear">×</button>
</div>
<div class="dsp-search-results" id="dsp-search-results"></div>
</div>
{% endif %}
{% if batch %}
<span style="width:1px;height:24px;background:#e2e8f0;margin:0 4px;"></span>
<a class="erp-btn {% if active_tab=='work' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/dispatch/batches/{{ batch.id }}"
data-ko="출고 작업" data-en="Dispatch Work">출고 작업</a>
<a class="erp-btn {% if active_tab=='picking' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/dispatch/batches/{{ batch.id }}/picking"
data-ko="피킹 요약" data-en="Picking Summary">피킹 요약</a>
<a class="erp-btn {% if active_tab=='handover' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/dispatch/batches/{{ batch.id }}/handover"
data-ko="Kagayaku 전달" data-en="Kagayaku Handover">Kagayaku 전달</a>
{% endif %}
<button id="dsp-lang-btn" type="button" class="erp-btn erp-btn-outline" style="margin-left:auto;font-weight:700;"
onclick="dspToggleLang()">EN</button>
</div>
<script>
// 한글/영문 토글. data-ko/data-en(텍스트), data-ko-ph/data-en-ph(placeholder) 교체.
(function () {
var KEY = 'dsp_lang';
window.dspApplyLang = function (lang) {
if (lang !== 'ko' && lang !== 'en') lang = 'ko';
document.querySelectorAll('[data-ko]').forEach(function (el) {
var v = el.getAttribute('data-' + lang);
if (v !== null) el.textContent = v;
});
document.querySelectorAll('[data-ko-ph]').forEach(function (el) {
var v = el.getAttribute('data-' + lang + '-ph');
if (v !== null) el.placeholder = v;
});
// 배치명은 저장된 데이터라 정적 번역 불가. 자동 기본 이름의 '출고'/'(이름없음)'만
// 영문에서 치환(직접 입력한 다른 한글 이름은 그대로 둔다).
document.querySelectorAll('[data-raw]').forEach(function (el) {
var raw = el.getAttribute('data-raw') || '';
el.textContent = (lang === 'en')
? raw.replace(/출고/g, 'Dispatch').replace('(이름없음)', '(no name)')
: raw;
});
var btn = document.getElementById('dsp-lang-btn');
if (btn) btn.textContent = (lang === 'ko') ? 'EN' : '한국어';
try { localStorage.setItem(KEY, lang); } catch (e) {}
document.documentElement.setAttribute('data-dsp-lang', lang);
document.dispatchEvent(new CustomEvent('dsp:lang', { detail: { lang: lang } }));
};
// 현재 언어(동적 텍스트 생성용).
window.dspLang = function () {
try { return localStorage.getItem(KEY) || 'ko'; } catch (e) { return 'ko'; }
};
window.dspToggleLang = function () {
var cur = 'ko';
try { cur = localStorage.getItem(KEY) || 'ko'; } catch (e) {}
window.dspApplyLang(cur === 'ko' ? 'en' : 'ko');
};
var init = 'ko';
try { init = localStorage.getItem(KEY) || 'ko'; } catch (e) {}
// DOM 준비 후 적용(스크립트가 상단이라 본문 요소가 아직일 수 있어 보강).
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { window.dspApplyLang(init); });
} else {
window.dspApplyLang(init);
}
})();
</script>
@@ -0,0 +1,382 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<style>
/* 배치 목록 가독성: 표 글자 약간 키움 */
.dsp-batches.erp-table td { font-size:15px; }
.dsp-batches.erp-table th { font-size:13px; }
/* 플랫폼 로고: 아이콘마다 여백이 달라 시각 크기를 맞춘다 */
.dsp-pf-logo { height:26px;width:auto;max-width:120px;vertical-align:middle;object-fit:contain; }
.dsp-pf-logo[src*="shopee"] { height:34px; }
/* 좌(리스트) / 우(달력) 2단 */
.dsp-layout { display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap; }
.dsp-list { flex:1 1 620px;min-width:320px; }
.dsp-calwrap { flex:0 1 340px;min-width:280px; }
/* 달력 */
.dsp-cal-head { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px; }
.dsp-cal-grid { display:grid;grid-template-columns:repeat(7,1fr);gap:4px; }
.dsp-cal-wd { text-align:center;font-size:12px;color:#94a3b8;padding:2px 0;font-weight:600; }
.dsp-cal-day { text-align:center;padding:8px 0;border-radius:8px;font-size:14px;color:#cbd5e1; }
.dsp-cal-day.has { color:#0f172a;background:#eef2ff;font-weight:700;cursor:pointer;border:1px solid #c7d2fe; }
.dsp-cal-day.has:hover { background:#e0e7ff; }
.dsp-cal-day.sel { background:#16a34a !important;border-color:#16a34a !important;color:#fff !important; }
/* 검색: _nav 행에서 「+ 새 업로드」오른쪽 30px, 결과는 절대위치 드롭다운 */
.dsp-search { position:relative;flex:0 1 420px;min-width:240px;margin-left:30px; }
.dsp-search-box { position:relative; }
.dsp-search-box .dsp-q-ico { position:absolute;left:11px;top:50%;transform:translateY(-50%);font-size:14px;color:#94a3b8;pointer-events:none; }
.dsp-search-box input { width:100%;padding:9px 30px 9px 32px;border:1px solid #cbd5e1;border-radius:8px;font-size:14px;box-sizing:border-box; }
.dsp-search-box input:focus { outline:none;border-color:#6366f1;box-shadow:0 0 0 3px rgba(99,102,241,.15); }
.dsp-q-clear { position:absolute;right:8px;top:50%;transform:translateY(-50%);border:0;background:transparent;color:#94a3b8;font-size:18px;cursor:pointer;line-height:1;padding:0 4px;display:none; }
.dsp-search-results { position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:40;background:#fff;border:1px solid #e2e8f0;border-radius:10px;box-shadow:0 8px 24px rgba(15,23,42,.12);max-height:64vh;overflow:auto;display:none;padding:6px; }
.dsp-search-results.open { display:block; }
.dsp-hit { border-radius:8px;padding:11px 12px;display:flex;justify-content:space-between;gap:12px;align-items:flex-start; }
.dsp-hit + .dsp-hit { border-top:1px solid #f1f5f9; }
.dsp-hit:hover { background:#f8fafc; }
.dsp-hit-body { min-width:0; }
.dsp-hit-name { font-weight:700;font-size:17px;color:#0f172a; }
.dsp-hit-meta { font-size:14px;color:#475569;margin-top:3px;line-height:1.5; }
/* Order / Pkg / 송장 — 한 줄씩 */
.dsp-id { font-size:14.5px;margin-top:4px;display:flex;gap:8px;align-items:baseline;flex-wrap:wrap; }
.dsp-id-k { color:#94a3b8;font-size:12px;min-width:42px;flex:0 0 auto; }
.dsp-id code { background:#f1f5f9;padding:2px 7px;border-radius:5px;font-size:14px;color:#0f172a; }
.dsp-id-track code { font-weight:700; }
.dsp-track-lnk { text-decoration:none; }
.dsp-track-lnk code { color:#4f46e5;text-decoration:underline;cursor:pointer; }
.dsp-track-lnk:hover code { background:#e0e7ff; }
.dsp-courier { font-size:12.5px;font-weight:600;color:#475569;background:#eef2ff;border:1px solid #c7d2fe;border-radius:999px;padding:1px 9px; }
.dsp-hit mark { background:#fde68a;padding:0 1px;border-radius:2px; }
.dsp-hit-batch { font-size:13px;color:#475569;margin-top:5px; }
.dsp-hit-go { white-space:nowrap;flex:0 0 auto;align-self:center; }
.dsp-hit-empty { color:#94a3b8;font-size:14.5px;padding:11px; }
</style>
{% endblock %}
{% block content %}
<section class="dsp">
{% include "dispatch/_nav.html" %}
<div class="dsp-layout">
<div class="erp-card dsp-list">
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;">
<h2><span data-ko="출고 배치" data-en="Batches">출고 배치</span> ({{ batches|length }})</h2>
<a class="erp-btn erp-btn-primary" href="/dispatch/batches/new" data-ko=" 새 업로드" data-en="+ New Upload"> 새 업로드</a>
</div>
<div class="erp-table-wrap">
<table class="erp-table dsp-batches">
<thead>
<tr>
<th data-ko="날짜" data-en="Date">날짜</th>
<th data-ko="플랫폼" data-en="Platform">플랫폼</th>
<th data-ko="배치명" data-en="Batch">배치명</th>
<th style="text-align:right" data-ko="박스" data-en="Boxes">박스</th>
<th style="text-align:right" data-ko="완료" data-en="Done">완료</th>
<th data-ko="생성시각" data-en="Created">생성시각</th><th></th>
</tr>
</thead>
<tbody>
{% for b in batches %}
<tr>
<td style="white-space:nowrap;">{{ b.dispatch_date }}</td>
<td>
{% set pf = (b.platform or '')|lower %}
{% if pf in ['tiktok', 'shopee'] %}
<img class="dsp-pf-logo" src="/static/dispatch/courier/{{ pf }}.png" alt="{{ b.platform }}" title="{{ b.platform }}" />
{% else %}{{ b.platform }}{% endif %}
</td>
<td><a href="/dispatch/batches/{{ b.id }}" style="font-weight:600;" class="dsp-bname"
data-raw="{{ b.batch_name or '(이름없음)' }}">{{ b.batch_name or '(이름없음)' }}</a></td>
<td style="text-align:right;">{{ b.parcel_count }}</td>
<td style="text-align:right;">
{% if b.parcel_count and b.done_count == b.parcel_count %}
<span style="color:#16a34a;font-weight:600;">{{ b.done_count }} ✓</span>
{% else %}{{ b.done_count }}{% endif %}
</td>
<td class="erp-muted" style="white-space:nowrap;">{{ (b.created_at[:19].replace('T',' ')) if b.created_at else '' }}</td>
<td style="text-align:right;white-space:nowrap;">
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}" data-ko="작업" data-en="Work">작업</a>
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}/picking" data-ko="피킹" data-en="Picking">피킹</a>
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}/handover" data-ko="전달" data-en="Handover">전달</a>
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}/download" data-ko="다운로드" data-en="Download">다운로드</a>
{% if is_super %}
<form method="post" action="/dispatch/batches/{{ b.id }}/delete" style="display:inline;"
onsubmit="return confirm('배치 「{{ b.batch_name or '(이름없음)' }}」 와 모든 박스/상품/로그를 삭제합니다. 되돌릴 수 없습니다. 계속할까요?');">
<button type="submit" class="erp-btn erp-btn-outline" style="color:#dc2626;border-color:#fca5a5;" data-ko="삭제" data-en="Delete">삭제</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
{% if not batches %}
<tr><td colspan="7" class="erp-muted" data-ko="아직 출고 배치가 없습니다. 위 + 새 업로드로 시작하세요." data-en="No batches yet. Start with + New Upload above.">아직 출고 배치가 없습니다. 위 + 새 업로드로 시작하세요.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<div class="erp-card dsp-calwrap">
<div class="cpg-card-head"><h3 style="margin:0;" data-ko="사방넷 출고 다운로드" data-en="Sabangnet Outbound Download">사방넷 출고 다운로드</h3></div>
<p class="erp-muted" style="margin:4px 0 10px;font-size:13px;"
data-ko="날짜를 클릭하면 그날 출고를 낱개로 분해한 사방넷 출고 엑셀을 받습니다. 사방넷에 올리면 출고로 잡혀 재고가 차감됩니다."
data-en="Pick a date for the Sabangnet outbound Excel (combos split into singles). Upload it to Sabangnet to register the shipment and deduct stock.">날짜를 클릭하면 그날 출고를 낱개로 분해한 사방넷 출고 엑셀을 받습니다. 사방넷에 올리면 출고로 잡혀 재고가 차감됩니다.</p>
<div class="dsp-cal" id="dsp-cal">
<div class="dsp-cal-head">
<button type="button" class="erp-btn erp-btn-outline" id="dsp-cal-prev"></button>
<span id="dsp-cal-title" style="font-weight:700;"></span>
<button type="button" class="erp-btn erp-btn-outline" id="dsp-cal-next"></button>
</div>
<div class="dsp-cal-grid" id="dsp-cal-grid"></div>
</div>
<div style="margin-top:12px;display:flex;flex-direction:column;gap:8px;">
<div class="erp-muted" style="font-size:13px;">
<span data-ko="선택한 날짜" data-en="Selected">선택한 날짜</span>:
<b id="dsp-cal-sel"></b>
</div>
<a id="dsp-cal-dl" class="erp-btn erp-btn-primary" href="#"
data-ko="📥 사방넷 출고 엑셀 다운로드" data-en="📥 Download Sabangnet Outbound Excel"
style="pointer-events:none;opacity:.5;">📥 사방넷 출고 엑셀 다운로드</a>
<button id="dsp-cal-deduct" type="button" class="erp-btn erp-btn-outline" onclick="dspDeduct()"
data-ko="📦 말레이시아 재고 차감" data-en="📦 Deduct Malaysia Stock"
style="pointer-events:none;opacity:.5;">📦 말레이시아 재고 차감</button>
<div id="dsp-cal-deduct-note" class="erp-muted" style="font-size:12px;"></div>
</div>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
// 배치가 있는 날짜(YYYY-MM-DD)
var BATCH_DATES = {{ batch_dates|tojson }};
var DEDUCTED = {};
({{ deducted_dates|tojson }}).forEach(function (d) { DEDUCTED[d] = true; });
var dateSet = {};
BATCH_DATES.forEach(function (d) { dateSet[d] = true; });
var grid = document.getElementById('dsp-cal-grid');
var title = document.getElementById('dsp-cal-title');
var selLabel = document.getElementById('dsp-cal-sel');
var dlBtn = document.getElementById('dsp-cal-dl');
var dedBtn = document.getElementById('dsp-cal-deduct');
var dedNote = document.getElementById('dsp-cal-deduct-note');
var selected = null;
// 초기 표시 월: 가장 최근 배치 날짜 기준(없으면 오늘)
var base = BATCH_DATES.length ? new Date(BATCH_DATES[BATCH_DATES.length - 1] + 'T00:00:00') : new Date();
var viewY = base.getFullYear(), viewM = base.getMonth();
var WD = { ko: ['일','월','화','수','목','금','토'], en: ['Su','Mo','Tu','We','Th','Fr','Sa'] };
function lang() { return (window.dspLang && window.dspLang() === 'en') ? 'en' : 'ko'; }
function pad(n) { return (n < 10 ? '0' : '') + n; }
function render() {
var l = lang();
title.textContent = viewY + '.' + pad(viewM + 1);
grid.innerHTML = '';
WD[l].forEach(function (w) {
var c = document.createElement('div');
c.className = 'dsp-cal-wd'; c.textContent = w; grid.appendChild(c);
});
var first = new Date(viewY, viewM, 1).getDay();
var days = new Date(viewY, viewM + 1, 0).getDate();
for (var i = 0; i < first; i++) grid.appendChild(document.createElement('div'));
for (var d = 1; d <= days; d++) {
var iso = viewY + '-' + pad(viewM + 1) + '-' + pad(d);
var cell = document.createElement('div');
cell.className = 'dsp-cal-day';
cell.textContent = d;
if (dateSet[iso]) {
cell.classList.add('has');
cell.setAttribute('data-iso', iso);
cell.onclick = function () { pick(this.getAttribute('data-iso')); };
}
if (iso === selected) cell.classList.add('sel');
grid.appendChild(cell);
}
}
function pick(iso) {
selected = iso;
selLabel.textContent = iso;
dlBtn.href = '/dispatch/stock-export?date=' + encodeURIComponent(iso);
dlBtn.style.pointerEvents = '';
dlBtn.style.opacity = '';
updateDeductBtn();
render();
}
function updateDeductBtn() {
var en = (window.dspLang && window.dspLang() === 'en');
if (!selected) {
dedBtn.style.pointerEvents = 'none'; dedBtn.style.opacity = '.5';
dedNote.textContent = '';
return;
}
if (DEDUCTED[selected]) {
dedBtn.style.pointerEvents = 'none'; dedBtn.style.opacity = '.5';
dedNote.textContent = en ? 'Already deducted (cannot run again).' : '이미 차감됨 (재실행 불가).';
} else {
dedBtn.style.pointerEvents = ''; dedBtn.style.opacity = '';
dedNote.textContent = '';
}
}
window.dspDeduct = function () {
if (!selected || DEDUCTED[selected]) return;
var en = (window.dspLang && window.dspLang() === 'en');
var msg = en
? ('Deduct Malaysia stock for ' + selected + '? This runs ONCE only and cannot be undone.')
: (selected + ' 출고를 말레이시아 재고에서 차감할까요?\n한 번만 실행되며 되돌릴 수 없습니다.');
if (!confirm(msg)) return;
dedBtn.style.pointerEvents = 'none'; dedBtn.style.opacity = '.5';
dedNote.textContent = en ? 'Processing…' : '처리 중…';
var body = new URLSearchParams(); body.set('date', selected);
fetch('/dispatch/stock-deduct', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (res) {
if (!res.ok) throw new Error((res.j && res.j.detail) || '실패');
DEDUCTED[selected] = true;
updateDeductBtn();
alert((en ? 'Done. singles ' : '완료. 낱개 ') + res.j.singles + (en ? ', combos ' : '종, 콤보 ') + res.j.combos + (en ? '' : '종 반영'));
})
.catch(function (e) {
dedNote.textContent = (e && e.message) || '실패';
updateDeductBtn();
alert((en ? 'Failed: ' : '실패: ') + ((e && e.message) || ''));
});
};
document.getElementById('dsp-cal-prev').onclick = function () {
viewM--; if (viewM < 0) { viewM = 11; viewY--; } render();
};
document.getElementById('dsp-cal-next').onclick = function () {
viewM++; if (viewM > 11) { viewM = 0; viewY++; } render();
};
document.addEventListener('dsp:lang', function () { render(); updateDeductBtn(); });
render();
})();
// ── 받는 사람 찾기(이름/주문/패키지/송장 번호 부분 일치) ──
(function () {
var wrap = document.getElementById('dsp-search');
var input = document.getElementById('dsp-q');
var box = document.getElementById('dsp-search-results');
var clearBtn = document.getElementById('dsp-q-clear');
if (!input || !box) return;
var timer = null, lastQ = '';
function open() { box.classList.add('open'); }
function close() { box.classList.remove('open'); }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// 매칭된 부분만 강조(이미 escape 된 문자열에 대해 안전하게 처리).
function hi(value, q) {
var s = esc(value);
if (!q) return s;
var i = s.toLowerCase().indexOf(q.toLowerCase());
if (i < 0) return s;
return s.slice(0, i) + '<mark>' + s.slice(i, i + q.length) + '</mark>' + s.slice(i + q.length);
}
function en() { return (window.dspLang && window.dspLang() === 'en'); }
function idLine(label, value, q) {
if (!value) return '';
return '<div class="dsp-id"><span class="dsp-id-k">' + label + '</span>'
+ '<code>' + hi(value, q) + '</code></div>';
}
function trackLine(r, q) {
if (!r.tracking_id) return '';
var num = '<code>' + hi(r.tracking_id, q) + '</code>';
// 송장번호 클릭 → 배송사 조회 페이지(딥링크 있으면).
var inner = r.tracking_url
? '<a href="' + esc(r.tracking_url) + '" target="_blank" rel="noopener" class="dsp-track-lnk">' + num + '</a>'
: num;
var courier = r.shipping_provider
? '<span class="dsp-courier">' + esc(r.shipping_provider) + '</span>'
: '';
return '<div class="dsp-id dsp-id-track"><span class="dsp-id-k">' + (en() ? 'Track' : '송장') + '</span>'
+ inner + courier + '</div>';
}
function row(r, q) {
var name = r.recipient_name
? hi(r.recipient_name, q)
: esc(en() ? '(no recipient name)' : '(받는 사람 정보 없음)');
var contact = [];
if (r.recipient_phone) contact.push(esc(r.recipient_phone));
if (r.recipient_address) contact.push(esc(r.recipient_address));
var done = r.label_attached && r.handed_to_kagayaku;
// 날짜·쇼핑몰은 굵게.
var batchLine = '<b>' + esc(r.dispatch_date) + '</b> · <b>' + esc(r.platform) + '</b> · '
+ esc(r.batch_name || '') + ' · ' + (en() ? 'Box #' : '박스 #') + esc(r.seq)
+ (done ? ' · <span style="color:#16a34a;font-weight:700;">✓</span>' : '');
return '<div class="dsp-hit">'
+ '<div class="dsp-hit-body">'
+ '<div class="dsp-hit-name">' + name + '</div>'
+ (contact.length ? '<div class="dsp-hit-meta">' + contact.join(' · ') + '</div>' : '')
+ idLine('Order', r.order_id, q)
+ idLine('Pkg', r.package_id, q)
+ trackLine(r, q)
+ '<div class="dsp-hit-batch">' + batchLine + '</div>'
+ '</div>'
+ '<a class="erp-btn erp-btn-outline dsp-hit-go" href="/dispatch/batches/' + encodeURIComponent(r.batch_id) + '">'
+ (en() ? 'Open' : '작업 열기') + '</a>'
+ '</div>';
}
function run(q) {
fetch('/dispatch/api/search?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (j) {
if (j.query !== input.value.trim()) return; // 더 최신 입력이 있으면 무시
var rs = j.results || [];
if (!rs.length) {
box.innerHTML = '<div class="dsp-hit-empty">'
+ (en() ? 'No match.' : '일치하는 박스가 없습니다.') + '</div>';
} else {
box.innerHTML = rs.map(function (r) { return row(r, q); }).join('');
}
open();
})
.catch(function () {
box.innerHTML = '<div class="dsp-hit-empty">'
+ (en() ? 'Search failed.' : '검색 실패.') + '</div>';
open();
});
}
function reset() {
box.innerHTML = ''; close(); lastQ = '';
if (clearBtn) clearBtn.style.display = 'none';
}
input.addEventListener('input', function () {
var q = input.value.trim();
if (clearBtn) clearBtn.style.display = q ? 'block' : 'none';
if (timer) clearTimeout(timer);
if (q.length < 2) { box.innerHTML = ''; close(); lastQ = ''; return; }
if (q === lastQ) { open(); return; }
lastQ = q;
timer = setTimeout(function () { run(q); }, 250);
});
// 결과가 있으면 포커스 시 다시 펼친다.
input.addEventListener('focus', function () { if (box.innerHTML) open(); });
if (clearBtn) clearBtn.addEventListener('click', function () { input.value = ''; reset(); input.focus(); });
input.addEventListener('keydown', function (e) { if (e.key === 'Escape') { close(); input.blur(); } });
// 바깥 클릭 시 닫기.
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
})();
</script>
{% endblock %}
@@ -0,0 +1,274 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<style>
.dsp-toolbar { display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0; }
.dsp-filter { display:flex;gap:6px;flex-wrap:wrap; }
.dsp-search { flex:0 1 240px;min-width:150px; }
.dsp-bulk { margin-left:auto;display:flex;gap:8px;flex-wrap:wrap; }
.dsp-bulk .dsp-btn-status { padding:8px 16px; }
.dsp-cards { display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px; }
.dsp-card { border:1px solid #e2e8f0;border-radius:12px;padding:14px;background:#fff;display:flex;flex-direction:column;gap:10px; }
.dsp-card { position:relative; }
.dsp-card.is-done { border-color:#86efac;background:#f0fdf4; }
.dsp-no { font-size:20px;font-weight:700;color:#0f172a; }
.dsp-courier-text { position:absolute;top:12px;right:14px; }
.dsp-meta { font-size:13px;line-height:1.5;color:#334155;word-break:break-all; }
.dsp-meta b { color:#0f172a; }
.dsp-order { font-size:15px;line-height:1.3;color:#334155;word-break:break-all; }
.dsp-order b { display:block;font-size:14px;font-weight:700;color:#475569;letter-spacing:.5px; }
.dsp-order span { font-size:27px;font-weight:800;color:#0f172a;letter-spacing:.2px; }
.dsp-recipient { border-top:1px dashed #e2e8f0;padding-top:8px; }
.dsp-rname { display:block;font-size:22px;font-weight:800;color:#0f172a;line-height:1.2;word-break:break-word; }
.dsp-raddr { font-size:14px;line-height:1.45;color:#334155;white-space:normal;word-break:break-word;margin-top:3px; }
.dsp-rphone { font-size:13px;color:#475569;margin-top:2px; }
.dsp-track-link { color:#2563eb !important;font-size:18px;font-weight:800;
text-decoration:underline !important;text-decoration-line:underline !important;
text-decoration-thickness:2px;text-underline-offset:3px;cursor:pointer; }
.dsp-track-link:hover { color:#1d4ed8 !important; }
.dsp-items { list-style:none;margin:0;padding:8px 0;border-top:1px dashed #e2e8f0;border-bottom:1px dashed #e2e8f0; }
.dsp-items li { font-size:16px;font-weight:600;color:#0f172a;padding:2px 0; }
.dsp-items .qty { color:#2563eb; }
.dsp-status { display:grid;grid-template-columns:1fr 1fr;gap:6px; }
.dsp-status .full { grid-column:1 / -1; }
.dsp-btn-status { border:1px solid #cbd5e1;border-radius:8px;padding:8px 6px;background:#f1f5f9;color:#475569;
font-weight:600;cursor:pointer;text-align:center;line-height:1.25;transition:background .12s,border-color .12s,color .12s; }
.dsp-btn-status small { display:block;font-size:10px;font-weight:500;opacity:.8; }
.dsp-btn-status.on { background:#16a34a;border-color:#16a34a;color:#fff; }
.dsp-btn-status:disabled { opacity:.6;cursor:wait; }
.dsp-courier { position:absolute;top:12px;right:14px;height:44px;width:auto;max-width:180px;object-fit:contain; }
/* SPX 로고는 가로로 넓어 같은 높이에서 더 커 보인다 — 틱톡 로고와 시각 크기 맞춤 */
.dsp-courier[src*="spx"] { height:30px;top:18px; }
.dsp-hidden { display:none !important; }
@media (max-width:480px){ .dsp-cards{grid-template-columns:1fr;} }
</style>
{% endblock %}
{% block content %}
<section class="dsp" data-batch="{{ batch.id }}">
{% include "dispatch/_nav.html" %}
<div class="erp-card">
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<h2><span data-ko="출고 작업 · 박스" data-en="Dispatch Work · Boxes">출고 작업 · 박스</span> {{ parcels|length }}</h2>
<span class="erp-muted" id="dsp-done-label"
data-done="{{ done_count }}" data-total="{{ parcels|length }}">완료 {{ done_count }} / {{ parcels|length }}</span>
</div>
<div class="dsp-toolbar">
<div class="dsp-filter" id="dsp-filter">
<button class="erp-btn erp-btn-primary" data-filter="all" type="button" data-ko="전체" data-en="All">전체</button>
<button class="erp-btn erp-btn-outline" data-filter="incomplete" type="button" data-ko="미완료" data-en="Incomplete">미완료</button>
<button class="erp-btn erp-btn-outline" data-filter="label_attached" type="button" data-ko="라벨부착" data-en="Label Attached">라벨부착</button>
<button class="erp-btn erp-btn-outline" data-filter="handed_to_kagayaku" type="button" data-ko="Kagayaku 전달" data-en="Kagayaku Handover">Kagayaku 전달</button>
</div>
<input class="erp-input dsp-search" id="dsp-search" type="search"
data-ko-ph="검색: Order ID / Tracking ID / 받는 사람 / SKU"
data-en-ph="Search: Order ID / Tracking ID / Recipient / SKU"
placeholder="검색: Order ID / Tracking ID / 받는 사람 / SKU" />
<div class="dsp-bulk">
<button class="dsp-btn-status" type="button" data-bulk="label_attached" onclick="dspBulk(this,'label_attached')"
data-ko="모두 라벨 부착" data-en="Label All">모두 라벨 부착</button>
<button class="dsp-btn-status" type="button" data-bulk="handed_to_kagayaku" onclick="dspBulk(this,'handed_to_kagayaku')"
data-ko="모두 Kagayaku 전달" data-en="Handover All">모두 Kagayaku 전달</button>
</div>
</div>
<div class="dsp-cards" id="dsp-cards">
{% for p in parcels %}
{% set is_done = p.label_attached and p.handed_to_kagayaku %}
<article class="dsp-card {% if is_done %}is-done{% endif %}"
data-parcel="{{ p.id }}"
data-label_attached="{{ p.label_attached|lower }}"
data-handed_to_kagayaku="{{ p.handed_to_kagayaku|lower }}"
data-search="{{ (p.order_id ~ ' ' ~ p.tracking_id ~ ' ' ~ p.recipient_name ~ ' ' ~ (p['items']|map(attribute='seller_sku')|join(' ')))|lower }}">
{% set logo = p.shipping_provider | courier_logo %}
{% if logo %}
<img class="dsp-courier" src="{{ logo }}" alt="{{ p.shipping_provider }}" title="{{ p.shipping_provider }}" />
{% elif p.shipping_provider %}
<span class="dsp-courier-text erp-badge">{{ p.shipping_provider }}</span>
{% endif %}
<div class="dsp-no">No. {{ p.seq }}</div>
{% if p.order_id %}
<div class="dsp-order"><b>Order ID</b><span>{{ p.order_id }}</span></div>
{% endif %}
{% if p.tracking_id %}
<div class="dsp-meta">
<div><b>Tracking</b>
{% set turl = p.tracking_id | courier_track(p.shipping_provider) %}
{% if turl %}<a class="dsp-track-link" href="{{ turl }}" target="_blank" rel="noopener noreferrer">{{ p.tracking_id }}</a>
{% else %}{{ p.tracking_id }}{% endif %}
</div>
</div>
{% endif %}
<div class="dsp-recipient">
<span class="dsp-rname">{{ p.recipient_name or '—' }}</span>
{% if p.recipient_address %}<div class="dsp-raddr">{{ p.recipient_address }}</div>{% endif %}
{% if p.recipient_phone %}<div class="dsp-rphone">{{ p.recipient_phone }}</div>{% endif %}
</div>
<ul class="dsp-items">
{% for it in p['items'] %}
<li>{% if batch.platform == 'Manual' %}{{ it.product_name or it.seller_sku }}{% else %}{{ it.seller_sku }}{% endif %} <span class="qty">× {{ it.quantity }}</span></li>
{% else %}
<li class="erp-muted" style="font-weight:400;" data-ko="상품 없음" data-en="No items">상품 없음</li>
{% endfor %}
</ul>
<div class="dsp-status">
{% for field in status_fields %}
<button type="button"
class="dsp-btn-status {% if (field == status_fields[-1]) and (status_fields|length % 2 == 1) %}full{% endif %} {% if p[field] %}on{% endif %}"
data-field="{{ field }}"
onclick="dspToggle(this)">
{{ status_labels[field].ko }}
<small>{{ status_labels[field].en }}</small>
</button>
{% endfor %}
</div>
</article>
{% endfor %}
{% if not parcels %}
<p class="erp-muted">이 배치에는 박스가 없습니다.</p>
{% endif %}
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
var STATUS_FIELDS = {{ status_fields|list|tojson }};
var BATCH_ID = document.querySelector('.dsp').getAttribute('data-batch');
// ── 일괄 토글 처리 (전부 완료면 해제, 아니면 완료) ──
var BULK_LABELS = {
ko: { label_attached: '라벨 부착', handed_to_kagayaku: 'Kagayaku 전달' },
en: { label_attached: 'Label Attached', handed_to_kagayaku: 'Kagayaku Handover' },
};
function allOn(field) {
var cards = document.querySelectorAll('.dsp-card');
if (!cards.length) return false;
return Array.prototype.every.call(cards, function (c) {
return c.getAttribute('data-' + field) === 'true';
});
}
// 버튼 상태(초록/외곽선) 동기화. 전부 완료면 on.
function refreshBulkButtons() {
document.querySelectorAll('.dsp-bulk [data-bulk]').forEach(function (btn) {
btn.classList.toggle('on', allOn(btn.getAttribute('data-bulk')));
});
}
window.dspBulk = function (btn, field) {
var target = !allOn(field); // 전부 완료면 해제, 아니면 완료
var en = (window.dspLang && window.dspLang() === 'en');
var label = BULK_LABELS[en ? 'en' : 'ko'][field] || field;
var msg = en
? ('Set ALL boxes "' + label + '" to ' + (target ? 'done' : 'not done') + '?')
: ('이 배치의 모든 박스를 "' + label + '" ' + (target ? '완료로 표시' : '완료 해제') + '할까요?');
if (!confirm(msg)) return;
document.querySelectorAll('.dsp-bulk button').forEach(function (b) { b.disabled = true; });
var body = new URLSearchParams();
body.set('field', field);
body.set('value', target ? 'true' : 'false');
fetch('/dispatch/batches/' + BATCH_ID + '/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function () { location.reload(); })
.catch(function () {
alert('일괄 처리에 실패했습니다. 다시 시도하세요.');
document.querySelectorAll('.dsp-bulk button').forEach(function (b) { b.disabled = false; });
});
};
// ── 상태 토글 (즉시 DB 저장) ──
window.dspToggle = function (btn) {
var card = btn.closest('.dsp-card');
var parcelId = card.getAttribute('data-parcel');
var field = btn.getAttribute('data-field');
btn.disabled = true;
var body = new URLSearchParams();
body.set('field', field);
fetch('/dispatch/parcels/' + parcelId + '/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (data) {
btn.classList.toggle('on', data.value);
card.setAttribute('data-' + field, data.value ? 'true' : 'false');
refreshDone(card);
applyFilter();
})
.catch(function () { alert('저장에 실패했습니다. 다시 시도하세요.'); })
.finally(function () { btn.disabled = false; });
};
function refreshDone(card) {
var done = STATUS_FIELDS.every(function (f) {
return card.getAttribute('data-' + f) === 'true';
});
card.classList.toggle('is-done', done);
updateDoneLabel();
refreshBulkButtons();
}
function updateDoneLabel() {
var cards = document.querySelectorAll('.dsp-card');
var done = document.querySelectorAll('.dsp-card.is-done').length;
var label = document.getElementById('dsp-done-label');
if (!label) return;
label.setAttribute('data-done', done);
label.setAttribute('data-total', cards.length);
var prefix = (window.dspLang && window.dspLang() === 'en') ? 'Done ' : '완료 ';
label.textContent = prefix + done + ' / ' + cards.length;
}
// 언어 변경 시 동적 라벨(완료 N/M) 다시 그림.
document.addEventListener('dsp:lang', updateDoneLabel);
// ── 필터 ──
var currentFilter = 'all';
function matchesFilter(card) {
switch (currentFilter) {
case 'all': return true;
case 'incomplete':
return !STATUS_FIELDS.every(function (f) {
return card.getAttribute('data-' + f) === 'true';
});
default: return card.getAttribute('data-' + currentFilter) === 'true';
}
}
function applyFilter() {
var term = (document.getElementById('dsp-search').value || '').trim().toLowerCase();
document.querySelectorAll('.dsp-card').forEach(function (card) {
var ok = matchesFilter(card) &&
(term === '' || (card.getAttribute('data-search') || '').indexOf(term) !== -1);
card.classList.toggle('dsp-hidden', !ok);
});
}
document.getElementById('dsp-filter').addEventListener('click', function (e) {
var btn = e.target.closest('[data-filter]');
if (!btn) return;
currentFilter = btn.getAttribute('data-filter');
this.querySelectorAll('[data-filter]').forEach(function (b) {
b.classList.toggle('erp-btn-primary', b === btn);
b.classList.toggle('erp-btn-outline', b !== btn);
});
applyFilter();
});
document.getElementById('dsp-search').addEventListener('input', applyFilter);
refreshBulkButtons(); // 초기 버튼 상태(전부 완료면 초록)
})();
</script>
{% endblock %}
@@ -0,0 +1,81 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<style>
.dsp-ho-grid { display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin:12px 0; }
.dsp-ho-stat { border:1px solid #e2e8f0;border-radius:10px;padding:12px;text-align:center;background:#fff; }
.dsp-ho-stat .n { font-size:26px;font-weight:700;color:#0f172a; }
.dsp-ho-stat .l { font-size:13px;color:#64748b; }
.dsp-track td { font-family:ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap; }
@media print {
.erp-sidebar, .erp-topbar, .erp-page-actions, .dsp-noprint { display:none !important; }
.erp-content, .erp-page, .erp-app { margin:0 !important;padding:0 !important;display:block !important; }
.erp-card { border:none !important;box-shadow:none !important;padding:0 !important; }
body { background:#fff !important; }
.dsp-print-head { display:block !important; }
@page { size:A4;margin:14mm; }
}
.dsp-print-head { display:none; }
</style>
{% endblock %}
{% block content %}
<section class="dsp">
{% include "dispatch/_nav.html" %}
<div class="dsp-noprint" style="margin:8px 0;">
<button class="erp-btn erp-btn-primary" type="button" onclick="window.print()" data-ko="🖨 인쇄하기" data-en="🖨 Print">🖨 인쇄하기</button>
</div>
<div class="erp-card">
<div class="dsp-print-head" style="margin-bottom:10px;">
<h2 style="margin:0;" data-ko="Kagayaku 전달 리스트" data-en="Kagayaku Handover List">Kagayaku 전달 리스트</h2>
</div>
<div class="cpg-card-head">
<h2 data-raw="{{ batch.batch_name or 'TikTok 출고' }}">{{ batch.batch_name or 'TikTok 출고' }}</h2>
</div>
<p class="erp-muted" style="margin:4px 0 4px;">
<span data-ko="날짜" data-en="Date">날짜</span> <b>{{ batch.dispatch_date }}</b> ·
<span data-ko="플랫폼" data-en="Platform">플랫폼</span> <b>{{ batch.platform }}</b>
</p>
<div class="dsp-ho-grid">
<div class="dsp-ho-stat">
<div class="n">{{ handover.total_boxes }}</div>
<div class="l" data-ko="총 박스 수" data-en="Total Boxes">총 박스 수</div>
</div>
{% for pv in handover.by_provider %}
<div class="dsp-ho-stat">
{% set logo = pv.provider | courier_logo %}
{% if logo %}<img src="{{ logo }}" alt="{{ pv.provider }}" style="height:40px;width:auto;max-width:260px;margin:0 auto 4px;display:block;" />{% endif %}
<div class="n">{{ pv.box_count }}</div>
<div class="l">{{ pv.provider }}</div>
</div>
{% endfor %}
</div>
<h3 style="margin:16px 0 8px;"><span data-ko="Tracking ID 목록" data-en="Tracking ID List">Tracking ID 목록</span> ({{ handover.tracking_list|length }})</h3>
<div class="erp-table-wrap">
<table class="erp-table dsp-track">
<thead>
<tr><th style="text-align:right">No</th><th>Tracking ID</th>
<th data-ko="배송사" data-en="Courier">배송사</th><th>Order ID</th></tr>
</thead>
<tbody>
{% for t in handover.tracking_list %}
<tr>
<td style="text-align:right">{{ t.seq }}</td>
<td>{{ t.tracking_id or '—' }}</td>
<td>{{ t.shipping_provider or '—' }}</td>
<td>{{ t.order_id or '—' }}</td>
</tr>
{% endfor %}
{% if not handover.tracking_list %}
<tr><td colspan="4" class="erp-muted" data-ko="전달할 박스가 없습니다." data-en="No boxes to hand over.">전달할 박스가 없습니다.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,109 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="dsp">
{% include "dispatch/_nav.html" %}
<div class="erp-card" style="max-width:640px;">
<div class="cpg-card-head"><h2 data-ko="출고 파일 업로드" data-en="Upload Dispatch Files">출고 파일 업로드</h2></div>
<p class="erp-muted" id="dsp-intro" style="margin:4px 0 16px;"></p>
<form method="post" action="/dispatch/batches" enctype="multipart/form-data" style="display:flex;flex-direction:column;gap:14px;">
<label style="display:flex;flex-direction:column;gap:4px;">
<span><span data-ko="출고 날짜" data-en="Dispatch Date">출고 날짜</span> <span style="color:#dc2626;">*</span></span>
<input class="erp-input" type="date" name="dispatch_date" value="{{ today }}" required />
</label>
<label style="display:flex;flex-direction:column;gap:4px;">
<span data-ko="플랫폼" data-en="Platform">플랫폼</span>
<select class="erp-select" name="platform" id="dsp-platform">
{% for pf in platforms %}
<option value="{{ pf }}">{{ pf }}</option>
{% endfor %}
</select>
</label>
<label style="display:flex;flex-direction:column;gap:4px;">
<span><span data-ko="배치명" data-en="Batch name">배치명</span>
<span class="erp-muted" data-ko="(예: 2026-06-19 오전 출고)" data-en="(e.g. 2026-06-19 AM)">(예: 2026-06-19 오전 출고)</span></span>
<input class="erp-input" type="text" name="batch_name"
data-ko-ph="오전 출고 / 오후 출고 등으로 구분" data-en-ph="e.g. AM dispatch / PM dispatch"
placeholder="오전 출고 / 오후 출고 등으로 구분" />
</label>
<label id="dsp-label-row" style="display:flex;flex-direction:column;gap:4px;">
<span id="dsp-label-lbl"></span>
<input class="erp-input" type="file" name="label_pdf" id="dsp-label" accept="application/pdf" />
</label>
<label style="display:flex;flex-direction:column;gap:4px;">
<span id="dsp-data-lbl"></span>
<input class="erp-input" type="file" name="data_xlsx" id="dsp-data"
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" required />
</label>
<div style="display:flex;gap:8px;margin-top:8px;">
<button class="erp-btn erp-btn-primary" type="submit" data-ko="업로드 후 배치 생성" data-en="Upload &amp; Create">업로드 후 배치 생성</button>
<a class="erp-btn erp-btn-outline" href="/dispatch/" data-ko="취소" data-en="Cancel">취소</a>
</div>
</form>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
// 플랫폼별 안내/파일 라벨(한/영). 데이터 엑셀 필수, 라벨 PDF 선택(보관·PII 추출용).
var CFG = {
ko: {
"TikTok": {
intro: "TikTok: Order Export.xlsx 로 출고 리스트가 생성됩니다. 받는 사람 이름/주소는 라벨 PDF 에서 가져오니 PDF 도 함께 올리세요.",
label: "📄 Shipping label + Packing slip.pdf <span class='erp-muted'>(받는 사람 이름/주소 추출 · 권장)</span>",
data: "📊 TikTok Order Export.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(자동 출고 리스트 기준)</span>"
},
"Shopee": {
intro: "Shopee: Packing List.Doorstep Delivery.xlsx 로 출고 리스트가 생성됩니다. 받는 사람 이름/주소는 Shopee Seller Centre.pdf 에서 가져오니 PDF 도 함께 올리세요.",
label: "📄 Shopee Seller Centre.pdf <span class='erp-muted'>(받는 사람 이름/주소 추출 · 권장)</span>",
data: "📊 Packing List.Doorstep Delivery.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(자동 출고 리스트 기준)</span>"
},
"Manual": {
intro: "메뉴얼 오더: 엑셀 파일만 올리세요. 첫 시트의 받는 사람/전화/주소/상품/수량을 읽어 출고 리스트를 만듭니다(PDF 불필요).",
label: "",
data: "📊 Manual Order.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(받는사람·상품·수량 포함)</span>"
}
},
en: {
"TikTok": {
intro: "TikTok: the dispatch list is built from Order Export.xlsx. Recipient name/address come from the label PDF, so please upload the PDF too.",
label: "📄 Shipping label + Packing slip.pdf <span class='erp-muted'>(recipient name/address · recommended)</span>",
data: "📊 TikTok Order Export.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(dispatch list source)</span>"
},
"Shopee": {
intro: "Shopee: the dispatch list is built from Packing List.Doorstep Delivery.xlsx. Recipient name/address come from Shopee Seller Centre.pdf, so please upload the PDF too.",
label: "📄 Shopee Seller Centre.pdf <span class='erp-muted'>(recipient name/address · recommended)</span>",
data: "📊 Packing List.Doorstep Delivery.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(dispatch list source)</span>"
},
"Manual": {
intro: "Manual order: upload the Excel file only. Recipient/phone/address/product/qty are read from the first sheet (no PDF needed).",
label: "",
data: "📊 Manual Order.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(includes recipient, product, qty)</span>"
}
}
};
var sel = document.getElementById('dsp-platform');
function apply() {
var lang = (window.dspLang && window.dspLang() === 'en') ? 'en' : 'ko';
var c = (CFG[lang] && CFG[lang][sel.value]) || CFG[lang]["TikTok"];
document.getElementById('dsp-intro').textContent = c.intro;
document.getElementById('dsp-label-lbl').innerHTML = c.label;
document.getElementById('dsp-data-lbl').innerHTML = c.data;
// 라벨(PDF) 슬롯이 없는 플랫폼(Manual)은 라벨 업로드 칸을 숨긴다.
document.getElementById('dsp-label-row').style.display = c.label ? '' : 'none';
}
sel.addEventListener('change', apply);
document.addEventListener('dsp:lang', apply); // 언어 토글 시 재적용
apply();
})();
</script>
{% endblock %}
@@ -0,0 +1,43 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<style>
.dsp-pick td.sku { font-size:18px;font-weight:700;color:#0f172a;white-space:nowrap; }
.dsp-pick td.qty { font-size:18px;font-weight:700;text-align:right;color:#2563eb;white-space:nowrap; }
</style>
{% endblock %}
{% block content %}
<section class="dsp">
{% include "dispatch/_nav.html" %}
<div class="erp-card" style="max-width:560px;">
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;">
<h2 data-ko="피킹 요약 (SKU별 총 수량)" data-en="Picking Summary (total qty by SKU)">피킹 요약 (SKU별 총 수량)</h2>
<span class="erp-muted"><span data-ko="총" data-en="Total"></span> {{ total_qty }} · {{ summary|length }} SKU</span>
</div>
<p class="erp-muted" style="margin:4px 0 12px;" data-ko="이 화면을 보고 먼저 전체 상품을 꺼내세요."
data-en="Pick all items first using this list.">이 화면을 보고 먼저 전체 상품을 꺼내세요.</p>
<div class="erp-table-wrap">
<table class="erp-table dsp-pick">
<thead>
<tr><th>Seller SKU</th><th data-ko="상품명" data-en="Product">상품명</th>
<th style="text-align:right" data-ko="수량" data-en="Qty">수량</th></tr>
</thead>
<tbody>
{% for r in summary %}
<tr>
<td class="sku">{{ r.seller_sku }}</td>
<td class="erp-muted">{{ r.product_name or '—' }}</td>
<td class="qty">{{ r.total_qty }}</td>
</tr>
{% endfor %}
{% if not summary %}
<tr><td colspan="3" class="erp-muted" data-ko="피킹할 상품이 없습니다." data-en="No items to pick.">피킹할 상품이 없습니다.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
+121
View File
@@ -0,0 +1,121 @@
"""dispatch 모듈 박스 묶기/SKU 합산 순수 로직 테스트.
DB/엑셀 없이 store.py 의 규칙만 검증한다.
python -m app.modules.dispatch.tests.test_grouping
또는 pytest 로 실행 가능.
"""
from __future__ import annotations
from app.modules.dispatch import store
def test_package_id_priority():
"""묶음키 우선순위: package_id > tracking_id > order_id."""
rows = [
{"order_id": "O1", "package_id": "P1", "tracking_id": "T1", "seller_sku": "MT-0320", "quantity": "1"},
{"order_id": "O1", "package_id": "P1", "tracking_id": "T1", "seller_sku": "MT-0320", "quantity": "1"},
{"order_id": "O1", "package_id": "P2", "tracking_id": "T2", "seller_sku": "MY-0001", "quantity": "1"},
]
parcels = store.group_parcels(rows)
assert len(parcels) == 2, parcels # P1, P2 → 박스 2개
p1 = next(p for p in parcels if p["package_id"] == "P1")
# 같은 박스 같은 SKU 수량 합산 → 2
assert p1["items"][0]["quantity"] == 2, p1
def test_tracking_then_order_fallback():
"""package_id 없으면 tracking_id, 그것도 없으면 order_id 로 묶는다."""
rows = [
{"order_id": "O9", "package_id": "", "tracking_id": "TRK", "seller_sku": "MT-0550", "quantity": ""},
{"order_id": "O9", "package_id": "", "tracking_id": "TRK", "seller_sku": "MX-0001", "quantity": "3"},
{"order_id": "O8", "package_id": "", "tracking_id": "", "seller_sku": "MT-0750", "quantity": "2"},
]
parcels = store.group_parcels(rows)
assert len(parcels) == 2, parcels
trk = next(p for p in parcels if p["tracking_id"] == "TRK")
assert len(trk["items"]) == 2 # 다른 SKU 2종
# quantity 빈칸 → 1 로 보정
qty_by_sku = {it["seller_sku"]: it["quantity"] for it in trk["items"]}
assert qty_by_sku["MT-0550"] == 1
assert qty_by_sku["MX-0001"] == 3
def test_quantity_and_tracking_preserved_as_string():
"""수량 비면 1, 송장번호 숫자로 들어와도 문자열 보존."""
assert store.normalize_quantity("") == 1
assert store.normalize_quantity(None) == 1
assert store.normalize_quantity("0") == 1
assert store.normalize_quantity("5") == 5
# 큰 송장번호가 float 로 읽혀도 과학표기/.0 없이 보존
assert store.clean_text(12345678901.0) == "12345678901"
assert store.clean_text(float("nan")) == ""
def test_sku_summary():
rows = [
{"order_id": "A", "package_id": "PA", "tracking_id": "", "seller_sku": "MT-0320_3", "quantity": "1"},
{"order_id": "B", "package_id": "PB", "tracking_id": "", "seller_sku": "MT-0320_3", "quantity": "1"},
{"order_id": "C", "package_id": "PC", "tracking_id": "", "seller_sku": "MY-0001", "quantity": "5"},
]
parcels = store.group_parcels(rows)
summary = store.sku_summary(parcels)
totals = {r["seller_sku"]: r["total_qty"] for r in summary}
assert totals == {"MT-0320_3": 2, "MY-0001": 5}, totals
def test_missing_required_columns():
"""필수 컬럼 누락 감지: seller_sku 없거나 묶음키 전무."""
mapping = store.resolve_columns(["Order ID", "Product Name", "Quantity"])
missing = store.missing_required(mapping)
assert any("Seller SKU" in m for m in missing), missing
# 묶음키(order id) 는 있으므로 그 누락 메시지는 없어야 함
assert not any("최소 1개" in m for m in missing), missing
def test_column_alias_strip_and_case():
"""헤더 앞뒤 공백/대소문자 무시하고 매핑."""
mapping = store.resolve_columns([" Order ID ", "TRACKING ID", "Seller SKU", "Quantity"])
assert "order_id" in mapping
assert "tracking_id" in mapping
assert "seller_sku" in mapping
def test_shopee_product_info_single():
blob = ("[1] Product Name:[1+1=3] Mira's Kitchen Korean Grade Miracle Food "
"Container Launch PROMO; Variation Name:3x 320ml; Price: RM 20.00; "
"Quantity: 1; SKU Reference No.: MT-0320_3;")
items = store.parse_shopee_product_info(blob)
assert len(items) == 1, items
it = items[0]
assert it["seller_sku"] == "MT-0320_3", it
assert it["quantity"] == 1, it
assert it["variation"] == "3x 320ml", it
assert "Mira's Kitchen" in it["product_name"], it
def test_shopee_product_info_multi():
blob = ("[1] Product Name:Item A; Variation Name:S; Quantity: 2; "
"SKU Reference No.: MT-0001; "
"[2] Product Name:Item B; Variation Name:L; Quantity: 3; "
"SKU Reference No.: MX-0002;")
items = store.parse_shopee_product_info(blob)
skus = {i["seller_sku"]: i["quantity"] for i in items}
assert skus == {"MT-0001": 2, "MX-0002": 3}, skus
def test_shopee_product_info_empty():
assert store.parse_shopee_product_info("") == []
assert store.parse_shopee_product_info(None) == []
def _run_all():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns:
fn()
print("PASS", fn.__name__)
print(f"\n{len(fns)} tests passed.")
if __name__ == "__main__":
_run_all()
+3
View File
@@ -9,12 +9,15 @@
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",
+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"])
+318 -28
View File
@@ -18,29 +18,21 @@ from typing import Any
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from .store import CATEGORIES, METHODS, STATUSES
from app.timezone import KST
_DDL = """
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 '작성중',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
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);
"""
from .store import APPROVED_STATUSES, CATEGORIES, METHODS, STATUSES
class ExpenseDBStore:
"""`ExpenseStore` 와 동일한 메서드 시그니처."""
"""`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(
@@ -48,17 +40,13 @@ class ExpenseDBStore:
min_size=min_size,
max_size=max_size,
kwargs={"row_factory": dict_row, "autocommit": True},
open=True,
open=False,
)
self._ensure_schema()
self._pool.open(wait=False)
def close(self) -> None:
self._pool.close()
def _ensure_schema(self) -> None:
with self._pool.connection() as conn:
conn.execute(_DDL)
# ── 조회 ──
def list_for(self, email: str) -> list[dict[str, Any]]:
email = email.lower().strip()
@@ -178,6 +166,307 @@ class ExpenseDBStore:
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()
@@ -218,10 +507,10 @@ class ExpenseDBStore:
out = dict(row)
if isinstance(out.get("spent_at"), date):
out["spent_at"] = out["spent_at"].isoformat()
for k in ("created_at", "updated_at"):
for k in ("created_at", "updated_at", "decided_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
out["amount"] = int(out.get("amount", 0))
return out
@@ -233,7 +522,8 @@ class ExpenseDBStore:
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 if category in CATEGORIES else "기타"
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
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:
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -13,17 +13,20 @@ import os
import tempfile
import threading
import uuid
from datetime import datetime, timezone
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 datetime.now(timezone.utc).isoformat(timespec="seconds")
return now_kst_iso()
class ExpenseStore:
@@ -148,7 +151,8 @@ class ExpenseStore:
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 if category in CATEGORIES else "기타"
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
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:
@@ -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 %}
+267 -87
View File
@@ -3,18 +3,25 @@
{% 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">
<div class="erp-summary-card">
<span class="erp-summary-label">총 건수</span>
<strong class="erp-summary-value">{{ summary.count }} 건</strong>
</div>
<div class="erp-summary-card">
<span class="erp-summary-label">총 금액</span>
<strong class="erp-summary-value" id="ex-total-amount">
{{ "{:,}".format(summary.total) }} 원
</strong>
</div>
{% for status in statuses %}
<div class="erp-summary-card erp-summary-card--mini">
<span class="erp-summary-label">{{ status }}</span>
@@ -25,40 +32,35 @@
{% 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>
<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>
<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>
<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>
<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="예) 스타벅스 강남점" />
<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>
<label class="erp-field erp-field-wide"><span>메모</span>
<input type="text" name="memo" placeholder="비고" />
</label>
<div class="erp-form-actions">
@@ -72,62 +74,170 @@
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>경비 내역</h2>
<span class="erp-muted" id="ex-count">{{ items | length }}건</span>
<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; text-align: right;">금액</th>
<th style="width: 120px;">금액</th>
<th style="width: 90px;">상태</th>
<th style="width: 130px; text-align: right;">동작</th>
<th style="width: 240px;"></th>
</tr>
</thead>
<tbody id="ex-tbody">
{% for it in items %}
<tr data-id="{{ it.id }}">
<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><span class="erp-badge erp-badge-neutral">{{ it.status }}</span></td>
<td style="text-align: right;">
<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>
<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="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>
<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 countEl = document.getElementById("ex-count");
const totalEl = document.getElementById("ex-total-amount");
const fmt = (n) => `${Number(n || 0).toLocaleString("ko-KR")} 원`;
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 || "";
@@ -143,11 +253,39 @@
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,
@@ -165,74 +303,116 @@
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
await reload();
form.reset();
setEditing("", null);
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");
const id = tr && tr.dataset.id;
if (!id) return;
if (btn.classList.contains("js-delete")) {
if (!confirm("이 항목을 삭제할까요?")) return;
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
if (res.ok) reload(); else alert("삭제 실패");
} else if (btn.classList.contains("js-edit")) {
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 j = await res.json();
const found = (j.items || []).find((x) => x.id === id);
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()}` });
}
});
async function reload() {
const res = await fetch(`/expense/api/items`);
if (!res.ok) return;
const j = await res.json();
const items = j.items || [];
const summary = j.summary || { count: 0, total: 0 };
countEl.textContent = `${items.length}건`;
if (totalEl) totalEl.textContent = fmt(summary.total);
tbody.innerHTML = "";
if (items.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>`;
return;
}
items
.sort((a, b) => (b.spent_at || "").localeCompare(a.spent_at || ""))
.forEach((it) => {
const tr = document.createElement("tr");
tr.dataset.id = it.id;
tr.innerHTML = `
<td>${it.spent_at || ""}</td>
<td>${it.category || ""}</td>
<td>${it.method || ""}</td>
<td><div>${it.merchant || ""}</div>${it.memo ? `<div class="erp-row-sub">${it.memo}</div>` : ""}</td>
<td style="text-align:right; font-variant-numeric: tabular-nums;">${fmt(it.amount)}</td>
<td><span class="erp-badge erp-badge-neutral">${it.status || "작성중"}</span></td>
<td style="text-align:right;">
<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>
</td>`;
tbody.appendChild(tr);
// 첨부 업로드
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 %}
+120
View File
@@ -0,0 +1,120 @@
# 말레이시아 창고 재고관리 모듈 (`malaysia`)
말레이시아 현지 창고의 입고/출고/조정과 세트 BOM, 일일 재고조사를 관리한다.
cupang 모듈과 동일한 패턴: **malaysia_stock_db(PostgreSQL) 전용**, JSON 폴백 없음.
상품명은 기존 `itemcode_db`(읽기 전용)을 재사용한다.
- 경로(prefix): `/malaysia`
- 권한키: `malaysia` (admin 은 항상 통과)
- 환경변수: `MALAYSIA_STOCK_DB_URL` (미설정 시 "설정 필요" 안내), `ITEMCODE_DB_URL`(세트 BOM/이름)
- DB: `malaysia_stock_db` / 역할 `malaysia_app`
> **세트 구성(BOM)은 별도 관리하지 않는다.** itemcode_db `set_components`
> (set_code / single_code / quantity)에서 읽는다. 모듈 안에 세트구성 메뉴 없음.
> `malaysia_stock_db.set_bom` 테이블은 더 이상 쓰지 않는다(레거시, 무시).
> 입출고·재고조사는 전체 아이템(+세트) 그리드에 **수량만 키인**해 일괄 등록한다.
---
## 재고 원칙
- **입고/출고/조정은 낱개 아이템(MT-/MX-/MZ-) 기준**으로만 movement 저장.
- **세트(MY-)는 movement 불가.** 세트는 ① 재고조사 입력, ② BOM 구성 계산에만 사용.
- 세트 주문 출고는 `set_bom` 으로 분해해 구성품별 `OUT` movement 를 생성(`/malaysia/movements/set-out`).
- 뚜껑(MD-)은 재고관리 대상에서 **완전 제외**(어디에도 사용 불가).
### 현재고 계산
```
현재고 = SUM(IN) - SUM(OUT) + SUM(ADJUST) + SUM(STOCKTAKE)
```
- `IN`/`OUT` qty 는 양수만. `ADJUST`/`STOCKTAKE` 는 음수(±) 허용.
- 재고조사 확정 시 **시스템 재고와 조사 최종치의 "차이"만** `STOCKTAKE` movement 로 기록 → 이력 보존 + 계산 단순.
### 일일 재고조사 집계
```
낱개 최종 재고(total_qty)
= direct_qty(낱개 직접 조사)
+ from_set_qty(세트 수량을 set_bom 으로 분해한 합)
```
예) `MT-0320` 낱개 100 + `MY-0002`(MT-0320 1개 포함) 20세트 → `MT-0320` total = **120**.
> 낱개 입력 시 **세트 안에 든 건 빼고** 낱개 보관분만 입력한다. 세트 안 상품은 BOM 으로 자동 계산.
### 랙(rack) 입력 — 재고조사 상세
재고조사 상세는 평면 수량 그리드 대신 **창고 랙 그림(A/B 두 면, 각 3행×3열×2분할 = 36칸)**
으로 입력한다. 각 칸에 `아이템 · 박스당 입수량 · 박스 수`를 넣고, 한 칸에 여러 아이템은
`+` 로 행을 추가한다. 저장하면 서버가 SKU별로
```
qty = SUM(units_per_box × box_count)
```
집계해 `daily_stocktake_line`을 **재생성**한다(랙이 라인의 단일 출처). 낱개·콤보(MY-)
모두 랙에 입력 가능하며, 이후 분해/확정 로직(`compute_result`/`finalize`)은 라인 기준으로
기존과 동일하게 동작한다. 칸 코드 형식: `{면}{행}-{열}-{분할}` (예 `A3-1-1`).
---
## 테이블 (`scripts/sql/malaysia_stock_db_init.sql`)
| 테이블 | 용도 |
| --- | --- |
| `warehouses` | 창고. seed: `MY-WH-01 / Malaysia Warehouse` |
| `malaysia_items` | 관리 대상 코드 스코프(낱개/세트) + 이름 스냅샷. seed: 낱개 12 + 세트 5 |
| `set_bom` | 세트 구성표. `UNIQUE(set_code, component_code)`. prefix CHECK 내장 |
| `stock_movement` | 입고/출고/조정/조사 이력. `item_code` 는 낱개만(CHECK) |
| `daily_stocktake` | 재고조사 헤더. 같은 날짜+창고 finalized 1건(부분 유니크) |
| `daily_stocktake_line` | 조사 라인(SKU별 최종 qty). 랙 입력 집계로 **재생성**됨. `UNIQUE(stocktake_id, sku_code)` |
| `daily_stocktake_rack` | 랙 칸별 입력(셀×SKU×입수량×박스수). 라인의 단일 출처. `cell_code``A3-1-1` |
검증 규칙(요구사항 8)은 **DB CHECK + 서비스 레이어(`store.py`)** 양쪽에 걸려 있다.
---
## 주요 화면 / API
화면: `/malaysia/`(재고 현황) · `/malaysia/movements`(입출고) · `/malaysia/stocktakes`(재고조사) · `/malaysia/sets`(세트 구성)
JSON API:
| Method | 경로 | 설명 |
| --- | --- | --- |
| GET | `/malaysia/api/warehouses` | 창고 목록 |
| GET | `/malaysia/api/items?kind=individual\|set` | 아이템 목록 |
| GET/POST | `/malaysia/api/bom` | 세트 구성 조회/등록 |
| GET | `/malaysia/api/stock?wh=` | 현재고 현황 |
| POST | `/malaysia/api/movements` | 낱개 movement 등록 |
| POST | `/malaysia/api/movements/set-out` | 세트 출고(BOM 분해) |
| GET | `/malaysia/api/stocktakes/{id}/result` | 재고조사 최종 계산 |
| POST | `/malaysia/stocktakes/{id}/rack/bulk` | 랙 입력 일괄 저장(병렬배열 `rk_cell/rk_sku/rk_upb/rk_box`) → 라인 재생성 |
---
## 초기화 (운영, 1회 — 사용자 승인 후)
```bash
read -s -p "malaysia_app password: " APP_PWD; echo
docker exec -i postgres-db psql -U postgres \
-v app_password="$APP_PWD" \
< scripts/sql/malaysia_stock_db_init.sql
# main-app .env 에 추가:
# MALAYSIA_STOCK_DB_URL=postgresql://malaysia_app:<APP_PWD>@postgres-db:5432/malaysia_stock_db
cd /opt/www/main && docker compose up -d --build
```
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. itemcode_db 는 건드리지 않음.
> **랙 입력 추가(`daily_stocktake_rack`)** 후 기존 운영 DB 에 반영하려면 위 init 스크립트를
> 그대로 1회 재실행한다(`CREATE TABLE IF NOT EXISTS` + grant 만 적용, 기존 데이터 보존).
## 테스트
```bash
python -m pytest tests/test_malaysia_stock.py -v # pytest 있으면
python tests/test_malaysia_stock.py # 없으면 standalone 폴백
```
순수 로직(코드 검증 / 세트 분해 / 재고조사 집계)만 검증하므로 DB 불필요.
+55
View File
@@ -0,0 +1,55 @@
"""말레이시아 창고 재고관리(malaysia) 모듈.
라우터/저장소/템플릿을 디렉토리에서 관리한다.
- 라우터: `router.py` (FastAPI APIRouter, prefix=/malaysia)
- 저장소: `db.py` (malaysia_stock_db / PostgreSQL 전용) + `store.py` (상수/순수 계산)
- 상품명: 전역 `app.state.itemcode_reader`(itemcode_db 읽기 전용) 재사용
- 템플릿: `templates/malaysia/`
데이터 저장은 malaysia_stock_db 전용이다. MALAYSIA_STOCK_DB_URL 미설정
build_malaysia_store None 반환하고, 라우터가 "설정 필요" 안내 페이지를
보여준다(앱은 죽지 않음).
"""
from typing import Any
from . import store
from .router import router
from .store import (
MOVEMENT_TYPES,
STOCKTAKE_STATUSES,
explode_set,
explode_stocktake,
missing_bom_sets,
)
__all__ = [
"router",
"store",
"MOVEMENT_TYPES",
"STOCKTAKE_STATUSES",
"explode_set",
"explode_stocktake",
"missing_bom_sets",
"build_malaysia_store",
"build_malaysia_itemcode",
]
def build_malaysia_store(*, dsn: str | None) -> Any:
"""MALAYSIA_STOCK_DB_URL 이 있으면 MalaysiaStockStore, 없으면 None.
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시.
"""
if not dsn:
return None
from .db import MalaysiaStockStore # 지연 import (개발 환경 deps 없을 수 있음)
return MalaysiaStockStore(dsn)
def build_malaysia_itemcode() -> Any:
"""itemcode_db 읽기 전용 세트 BOM 리더. ITEMCODE_DB_URL 없으면 비활성."""
from .itemcode import MalaysiaItemcodeReader # 지연 import
return MalaysiaItemcodeReader()
+999
View File
@@ -0,0 +1,999 @@
"""malaysia_stock_db PostgreSQL 저장소.
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) cupang/expense 모듈과 동일 패턴.
- 연결 정보: 환경변수 `MALAYSIA_STOCK_DB_URL`
(: postgresql://malaysia_app:<pwd>@postgres-db:5432/malaysia_stock_db)
- 스키마(테이블/인덱스/트리거/seed) 앱이 만들지 않는다.
`scripts/sql/malaysia_stock_db_init.sql` superuser 사전 적용한다.
계정(malaysia_app) SELECT/INSERT/UPDATE/DELETE 권한만 받는다.
- 연결 풀은 lazy open 부팅 DB 잠시 끊겨도 컨테이너가 죽지 않게.
재고 계산 / BOM 분해의 순수 로직은 `store.py` 있고, 여기서는 DB I/O
조립만 담당한다(클라이언트 계산을 신뢰하지 않고 서버에서 재계산).
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from typing import Any
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from app.timezone import KST, now_kst
from . import store
logger = logging.getLogger("malaysia.db")
class MalaysiaStockStore:
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()
# ════════════════════════════════════════════════════════════
# 창고 (warehouses)
# ════════════════════════════════════════════════════════════
def list_warehouses(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 warehouses {where} "
"ORDER BY active DESC, warehouse_code ASC"
).fetchall()
return [self._serialize(r) for r in rows]
def get_warehouse(self, *, warehouse_code: str) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM warehouses WHERE warehouse_code = %s",
(warehouse_code,),
).fetchone()
return self._serialize(row) if row else None
def create_warehouse(
self, *, warehouse_code: str, warehouse_name: str, country: str = "MY"
) -> dict[str, Any]:
code = (warehouse_code or "").strip()
name = (warehouse_name or "").strip()
if not code or not name:
raise ValueError("창고코드와 창고명은 필수입니다.")
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO warehouses (warehouse_code, warehouse_name, country)
VALUES (%s, %s, %s)
ON CONFLICT (warehouse_code) DO UPDATE
SET warehouse_name = EXCLUDED.warehouse_name,
country = EXCLUDED.country,
active = TRUE
RETURNING *
""",
(code, name, (country or "MY").strip()),
).fetchone()
return self._serialize(row)
# ════════════════════════════════════════════════════════════
# 재고관리 대상 아이템 (malaysia_items)
# ════════════════════════════════════════════════════════════
def list_items(
self, *, kind: str | None = None, include_inactive: bool = False
) -> list[dict[str, Any]]:
"""kind: 'individual' | 'set' | None(전체)."""
clauses: list[str] = []
params: list[Any] = []
if not include_inactive:
clauses.append("active = TRUE")
if kind in ("individual", "set"):
clauses.append("kind = %s")
params.append(kind)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM malaysia_items {where} "
"ORDER BY sort_order ASC, item_code ASC",
params,
).fetchall()
return [self._serialize(r) for r in rows]
def item_name_map(self) -> dict[str, str]:
"""item_code → item_name (이름 붙이기용)."""
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT item_code, item_name FROM malaysia_items"
).fetchall()
return {r["item_code"]: (r["item_name"] or r["item_code"]) for r in rows}
def upsert_item(
self, *, item_code: str, item_name: str, kind: str, sort_order: int = 0
) -> dict[str, Any]:
code = store._norm(item_code)
name = (item_name or "").strip()
if kind not in ("individual", "set"):
raise ValueError("kind 는 'individual' 또는 'set' 이어야 합니다.")
# prefix 와 kind 정합성 검증
if kind == "individual" and not store.is_individual_code(code):
raise ValueError(f"낱개 아이템은 MT-/MX-/MZ- 만 가능합니다: {code}")
if kind == "set" and not store.is_set_code(code):
raise ValueError(f"세트 아이템은 MY- 만 가능합니다: {code}")
if store.is_blocked_code(code):
raise ValueError(f"뚜껑 코드(MD-)는 등록할 수 없습니다: {code}")
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO malaysia_items (item_code, item_name, kind, sort_order)
VALUES (%s, %s, %s, %s)
ON CONFLICT (item_code) DO UPDATE
SET item_name = EXCLUDED.item_name,
kind = EXCLUDED.kind,
sort_order = EXCLUDED.sort_order,
active = TRUE
RETURNING *
""",
(code, name or code, kind, sort_order),
).fetchone()
return self._serialize(row)
def sync_item_names(self, name_lookup: dict[str, str]) -> int:
"""itemcode_db 등 외부 소스에서 받은 {code: name} 으로 이름 갱신.
등록된 코드만 갱신(신규 추가 ). 반환: 갱신 건수.
"""
updated = 0
with self._pool.connection() as conn:
for code, name in name_lookup.items():
if not code or not name:
continue
cur = conn.execute(
"UPDATE malaysia_items SET item_name = %s "
"WHERE item_code = %s AND item_name <> %s",
(name.strip(), store._norm(code), name.strip()),
)
updated += cur.rowcount or 0
return updated
# ════════════════════════════════════════════════════════════
# 세트 구성표 (BOM)
# 별도 테이블/메뉴를 두지 않고 itemcode_db(set_components)에서 읽는다.
# 라우터가 MalaysiaItemcodeReader.set_bom_map() 결과를 아래 메서드들에
# bom_map 인자로 주입한다(cross-DB 조인 회피, store 는 DB I/O 만 담당).
# ════════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════════
# 재고 이동 (stock_movement)
# ════════════════════════════════════════════════════════════
def create_movement(
self,
*,
movement_date: str,
warehouse_code: str,
item_code: str,
movement_type: str,
qty: int,
ref_type: str = "",
ref_no: str = "",
memo: str = "",
created_by: str = "",
) -> dict[str, Any]:
"""단일 낱개 아이템 movement 1건 생성. 세트 코드는 차단."""
if movement_type not in store.MOVEMENT_TYPES:
raise ValueError(f"허용되지 않는 이동 종류: {movement_type}")
code = store.validate_movement_code(item_code) # MY-/MD- 차단
# IN/OUT 은 양수, ADJUST/STOCKTAKE 는 ± 허용
if movement_type in ("IN", "OUT"):
n = store.validate_qty(qty, allow_zero=False, allow_negative=False)
else:
n = store.validate_qty(qty, allow_zero=True, allow_negative=True)
if not (movement_date or "").strip():
raise ValueError("이동일자는 필수입니다.")
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO stock_movement
(movement_date, warehouse_code, item_code, movement_type,
qty, ref_type, ref_no, memo, created_by)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
movement_date.strip(),
warehouse_code.strip(),
code,
movement_type,
n,
(ref_type or "").strip(),
(ref_no or "").strip(),
(memo or "").strip(),
(created_by or "").lower().strip(),
),
).fetchone()
return self._serialize(row)
def create_set_out(
self,
*,
movement_date: str,
warehouse_code: str,
set_code: str,
set_qty: int,
bom_map: dict[str, list[dict[str, Any]]],
ref_type: str = "",
ref_no: str = "",
memo: str = "",
created_by: str = "",
) -> list[dict[str, Any]]:
"""세트 주문 출고 → BOM(itemcode_db)으로 분해해 구성품별 OUT movement 생성.
: MY-0002 1세트 출고 MT-0320, MT-0550 ... 각각 OUT.
콤보는 입고 불가(낱개로만 입고). 출고만 BOM 분해한다.
bom_map 라우터가 itemcode 리더에서 받아 주입. 구성 없으면 ValueError.
"""
sc = store.validate_bom_set_code(set_code)
q = store.validate_qty(set_qty, allow_zero=False)
exploded = store.explode_set(sc, q, bom_map)
if not exploded:
raise ValueError(f"세트 {sc} 의 BOM 구성이 없습니다. 세트구성을 먼저 등록하세요.")
ref_t = (ref_type or "set").strip() or "set"
results: list[dict[str, Any]] = []
with self._pool.connection() as conn:
with conn.transaction():
for comp_code, comp_qty in exploded.items():
row = conn.execute(
# 구성 낱개는 콤보 분해분 — ref_type 을 SET_COMPONENT_REF 로 고정해
# 이동이력에서 '낱개 입력' 줄과 구분(숨김)한다. 재고 계산엔 포함.
"""
INSERT INTO stock_movement
(movement_date, warehouse_code, item_code, movement_type,
qty, ref_type, ref_no, memo, created_by)
VALUES (%s,%s,%s,'OUT',%s,%s,%s,%s,%s)
RETURNING *
""",
(
movement_date.strip(),
warehouse_code.strip(),
comp_code,
comp_qty,
store.SET_COMPONENT_REF,
(ref_no or "").strip(),
(memo or f"{sc} {q}세트 분해").strip(),
(created_by or "").lower().strip(),
),
).fetchone()
results.append(self._serialize(row))
# 콤보(세트) 단위 출고 원장 — 콤보 재고현황 차감용.
conn.execute(
"""
INSERT INTO set_movement
(movement_date, warehouse_code, set_code, movement_type,
qty, ref_type, ref_no, memo, created_by)
VALUES (%s,%s,%s,'OUT',%s,%s,%s,%s,%s)
""",
(
movement_date.strip(),
warehouse_code.strip(),
sc,
q,
ref_t,
(ref_no or "").strip(),
(memo or f"{sc} {q}세트 출고").strip(),
(created_by or "").lower().strip(),
),
)
return results
def list_movements(
self,
*,
warehouse_code: str | None = None,
item_code: str | None = None,
movement_type: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
limit: int = 500,
) -> list[dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if warehouse_code:
clauses.append("warehouse_code = %s")
params.append(warehouse_code)
if item_code:
clauses.append("item_code = %s")
params.append(store._norm(item_code))
if movement_type in store.MOVEMENT_TYPES:
clauses.append("movement_type = %s")
params.append(movement_type)
if date_from:
clauses.append("movement_date >= %s")
params.append(date_from)
if date_to:
clauses.append("movement_date <= %s")
params.append(date_to)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
params.append(int(limit))
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM stock_movement {where} "
"ORDER BY movement_date DESC, id DESC LIMIT %s",
params,
).fetchall()
return [self._serialize(r) for r in rows]
def list_set_movements(
self,
*,
warehouse_code: str | None = None,
movement_type: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
limit: int = 500,
) -> list[dict[str, Any]]:
"""콤보(세트) 출고 원장(set_movement) 목록. 이동이력 콤보 줄 표시용."""
clauses: list[str] = []
params: list[Any] = []
if warehouse_code:
clauses.append("warehouse_code = %s")
params.append(warehouse_code)
if movement_type in store.MOVEMENT_TYPES:
clauses.append("movement_type = %s")
params.append(movement_type)
if date_from:
clauses.append("movement_date >= %s")
params.append(date_from)
if date_to:
clauses.append("movement_date <= %s")
params.append(date_to)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
params.append(int(limit))
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM set_movement {where} "
"ORDER BY movement_date DESC, id DESC LIMIT %s",
params,
).fetchall()
return [self._serialize(r) for r in rows]
def daily_movement_totals(
self, *, warehouse_code: str, movement_date: str, movement_type: str
) -> dict[str, int]:
"""특정 일자/종류의 낱개 아이템별 합계 수량.
세트 OUT 생성 이미 구성품 OUT 으로 분해 저장되므로 그대로 합산된다.
반환: { item_code: 합계수량 }
"""
if movement_type not in store.MOVEMENT_TYPES:
raise ValueError(f"허용되지 않는 이동 종류: {movement_type}")
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT item_code, SUM(qty) AS qty
FROM stock_movement
WHERE warehouse_code = %s
AND movement_date = %s
AND movement_type = %s
GROUP BY item_code
ORDER BY item_code
""",
(warehouse_code, movement_date, movement_type),
).fetchall()
return {r["item_code"]: int(r["qty"] or 0) for r in rows}
def current_stock(self, *, warehouse_code: str) -> dict[str, int]:
"""창고별 현재고(낱개 아이템 기준).
현재고 = SUM(IN) - SUM(OUT) + SUM(ADJUST) + SUM(STOCKTAKE)
반환: { item_code: qty }
"""
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT item_code,
SUM(CASE
WHEN movement_type = 'IN' THEN qty
WHEN movement_type = 'OUT' THEN -qty
ELSE qty -- ADJUST/STOCKTAKE 부호 포함 저장
END) AS qty
FROM stock_movement
WHERE warehouse_code = %s
GROUP BY item_code
""",
(warehouse_code,),
).fetchall()
return {r["item_code"]: int(r["qty"] or 0) for r in rows}
def stock_status(self, *, warehouse_code: str) -> list[dict[str, Any]]:
"""재고 현황 — 등록된 낱개 아이템 전체 + 현재고 + 마지막 조사일.
등록 아이템 기준으로 0 재고도 표시(좌측 조인 효과).
"""
stock = self.current_stock(warehouse_code=warehouse_code)
last_dates = self._last_stocktake_dates(warehouse_code=warehouse_code)
items = self.list_items(kind="individual")
out: list[dict[str, Any]] = []
for it in items:
code = it["item_code"]
out.append(
{
"item_code": code,
"item_name": it.get("item_name") or code,
"current_qty": stock.get(code, 0),
"last_stocktake_date": last_dates.get(code),
}
)
# 등록 아이템에 없지만 movement 가 있는 코드도 노출(누락 방지)
for code, qty in stock.items():
if code not in {i["item_code"] for i in out}:
out.append(
{
"item_code": code,
"item_name": code,
"current_qty": qty,
"last_stocktake_date": last_dates.get(code),
}
)
out.sort(key=lambda x: x["item_code"])
return out
def latest_stocktake(self, *, warehouse_code: str) -> dict[str, Any] | None:
"""가장 최근(취소 제외) 재고조사 헤더. 없으면 None."""
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM daily_stocktake "
"WHERE warehouse_code = %s AND status <> 'cancelled' "
"ORDER BY stocktake_date DESC, id DESC LIMIT 1",
(warehouse_code,),
).fetchone()
return self._serialize(row) if row else None
def latest_rack_stocktake(self, *, warehouse_code: str) -> dict[str, Any] | None:
"""랙 입력을 가장 최근에 저장한(취소 제외) 재고조사 헤더. 없으면 None.
랙보기 전용. '최근' 조사 날짜가 아니라 저장 시각으로 판단한다.
replace_rack 저장마다 daily_stocktake_rack delete+insert 하므로
created_at MAX 마지막 저장 시각이 된다. 항목이 없는
조사는 보여줄 없으므로 자연히 제외된다.
"""
with self._pool.connection() as conn:
row = conn.execute(
"SELECT s.* FROM daily_stocktake s "
"JOIN daily_stocktake_rack r ON r.stocktake_id = s.id "
"WHERE s.warehouse_code = %s AND s.status <> 'cancelled' "
"GROUP BY s.id "
"ORDER BY MAX(r.created_at) DESC, s.id DESC LIMIT 1",
(warehouse_code,),
).fetchone()
return self._serialize(row) if row else None
def previous_rack_entries(
self, *, warehouse_code: str, exclude_stocktake_id: int
) -> list[dict[str, Any]]:
"""직전(현재 조사 제외, 취소 제외, 랙 입력 있는) 재고조사의 랙 항목.
'이전값 불러오기' 버튼용. 조사 날짜 기준 가장 최근 다른 조사를 고른다.
replace_rack entries 형식(cell_code/sku_code/units_per_box/box_count)
호환되는 list_rack_entries 결과를 그대로 반환. 없으면 [].
"""
with self._pool.connection() as conn:
head = conn.execute(
"SELECT s.id FROM daily_stocktake s "
"JOIN daily_stocktake_rack r ON r.stocktake_id = s.id "
"WHERE s.warehouse_code = %s AND s.id <> %s AND s.status <> 'cancelled' "
"GROUP BY s.id "
"ORDER BY MAX(r.created_at) DESC, s.id DESC LIMIT 1",
(warehouse_code, exclude_stocktake_id),
).fetchone()
if not head:
return []
return self.list_rack_entries(stocktake_id=head["id"])
def reposition_rack(
self, *, stocktake_id: int, entries: list[dict[str, Any]]
) -> dict[str, Any]:
"""랙 위치 재배치 전용(랙 보기 드래그 저장).
daily_stocktake_rack (cell_code) 배치만 교체한다. 위치 이동은
SKU 총수량을 바꾸지 않으므로 daily_stocktake_line 건드리지 않는다
(확정본의 집계/전산재고 보존). 확정(finalized) 조사도 허용 위치만
변경. 취소된 조사는 불가. entries 형식은 replace_rack 동일.
"""
valid_cells = set(store.rack_cell_codes())
clean: list[tuple[str, str, int, int]] = []
for e in entries:
cell = (e.get("cell_code") or "").strip().upper()
if cell not in valid_cells:
raise ValueError(f"알 수 없는 랙 위치: {cell or '(빈값)'}")
code = store.validate_rack_code(str(e.get("sku_code") or ""))
upb = store.validate_qty(
e.get("units_per_box"), allow_zero=False, allow_negative=False
)
box = store.validate_qty(
e.get("box_count"), allow_zero=False, allow_negative=False
)
clean.append((cell, code, upb, box))
cell_seq: dict[str, int] = {}
with self._pool.connection() as conn:
with conn.transaction():
head = conn.execute(
"SELECT status FROM daily_stocktake WHERE id = %s FOR UPDATE",
(stocktake_id,),
).fetchone()
if not head:
raise KeyError(stocktake_id)
if head["status"] == "cancelled":
raise ValueError("취소된 조사는 수정할 수 없습니다.")
conn.execute(
"DELETE FROM daily_stocktake_rack WHERE stocktake_id = %s",
(stocktake_id,),
)
for cell, code, upb, box in clean:
ln = cell_seq.get(cell, 0)
cell_seq[cell] = ln + 1
conn.execute(
"""
INSERT INTO daily_stocktake_rack
(stocktake_id, cell_code, line_no, sku_code,
units_per_box, box_count)
VALUES (%s,%s,%s,%s,%s,%s)
""",
(stocktake_id, cell, ln, code, upb, box),
)
return {"entries": len(clean)}
def delete_stocktake(self, *, stocktake_id: int) -> None:
"""재고조사 완전 삭제(헤더+라인 CASCADE). 슈퍼관리자 전용 동작.
주의: finalize 생성된 STOCKTAKE movement 별도이므로 같이 지우지 않는다
(재고 이력 보존). 헤더/라인만 제거.
"""
with self._pool.connection() as conn:
cur = conn.execute("DELETE FROM daily_stocktake WHERE id = %s", (stocktake_id,))
if cur.rowcount == 0:
raise KeyError(stocktake_id)
def latest_set_quantities(self, *, warehouse_code: str) -> dict[str, Any]:
"""콤보(MY-) 단위 재고현황 — 우측 패널용.
콤보는 movement 테이블(낱개 전용) 직접 들어가므로, 재고조사 입력을
기준점으로 set_movement(콤보 출고 원장) 차감해 현재 콤보 재고를 낸다.
콤보재고 = 최근(취소 제외) 재고조사 콤보수량 재고조사 기록 이후 콤보 OUT
차감 기준은 날짜가 아니라 재고조사 생성시각(created_at)이다. 같은
재고조사 출고해도 반영되도록(날짜만 비교하면 당일 출고가 누락됨).
재고조사를 다시 카운트하면 기준점이 갱신돼 이전 출고는 자동 리셋된다.
반환: {"stocktake_date": str|None,
"rows": [{set_code, set_name, stocktake_qty, out_qty, qty}]}
"""
with self._pool.connection() as conn:
head = conn.execute(
"SELECT id, stocktake_date, created_at FROM daily_stocktake "
"WHERE warehouse_code = %s AND status <> 'cancelled' "
"ORDER BY stocktake_date DESC, id DESC LIMIT 1",
(warehouse_code,),
).fetchone()
if not head:
return {"stocktake_date": None, "rows": []}
lines = conn.execute(
"SELECT sku_code, qty FROM daily_stocktake_line "
"WHERE stocktake_id = %s AND sku_code LIKE 'MY-%%' "
"ORDER BY sku_code ASC",
(head["id"],),
).fetchall()
# 재고조사 기록 시점 이후의 콤보 출고(OUT)/조정(ADJUST) 합.
# created_at 비교라 같은 날 출고도 반영된다.
out_rows = conn.execute(
"""
SELECT set_code,
SUM(CASE WHEN movement_type = 'OUT' THEN qty ELSE -qty END) AS out_qty
FROM set_movement
WHERE warehouse_code = %s AND created_at > %s
GROUP BY set_code
""",
(warehouse_code, head["created_at"]),
).fetchall()
out_map = {r["set_code"]: int(r["out_qty"] or 0) for r in out_rows}
names = self.item_name_map()
d = head["stocktake_date"]
date_str = d.isoformat() if isinstance(d, date) else str(d)
rows = [
{
"set_code": r["sku_code"],
"set_name": names.get(r["sku_code"], r["sku_code"]),
"stocktake_qty": int(r["qty"]),
"out_qty": out_map.get(r["sku_code"], 0),
"qty": int(r["qty"]) - out_map.get(r["sku_code"], 0),
}
for r in lines
]
return {"stocktake_date": date_str, "rows": rows}
def _last_stocktake_dates(self, *, warehouse_code: str) -> dict[str, str]:
"""아이템별 마지막 STOCKTAKE movement 날짜."""
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT item_code, MAX(movement_date) AS d
FROM stock_movement
WHERE warehouse_code = %s AND movement_type = 'STOCKTAKE'
GROUP BY item_code
""",
(warehouse_code,),
).fetchall()
out: dict[str, str] = {}
for r in rows:
d = r["d"]
out[r["item_code"]] = d.isoformat() if isinstance(d, date) else str(d)
return out
# ════════════════════════════════════════════════════════════
# 일일 재고조사 (daily_stocktake + daily_stocktake_line)
# ════════════════════════════════════════════════════════════
def list_stocktakes(
self, *, warehouse_code: str | None = None, limit: int = 200
) -> list[dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if warehouse_code:
clauses.append("warehouse_code = %s")
params.append(warehouse_code)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
params.append(int(limit))
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM daily_stocktake {where} "
"ORDER BY stocktake_date DESC, id DESC LIMIT %s",
params,
).fetchall()
return [self._serialize(r) for r in rows]
def get_stocktake(
self, *, stocktake_id: int, with_lines: bool = True
) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM daily_stocktake WHERE id = %s", (stocktake_id,)
).fetchone()
if not row:
return None
head = self._serialize(row)
if with_lines:
line_rows = conn.execute(
"SELECT * FROM daily_stocktake_line WHERE stocktake_id = %s "
"ORDER BY sku_code ASC",
(stocktake_id,),
).fetchall()
head["lines"] = [self._serialize(r) for r in line_rows]
return head
def create_stocktake(
self,
*,
stocktake_date: str,
warehouse_code: str,
memo: str = "",
created_by: str = "",
) -> dict[str, Any]:
if not (stocktake_date or "").strip():
raise ValueError("조사 날짜는 필수입니다.")
if not (warehouse_code or "").strip():
raise ValueError("창고는 필수입니다.")
# 같은 날짜+창고에 이미 finalized 가 있으면 신규 생성 차단(요구사항 8)
with self._pool.connection() as conn:
dup = conn.execute(
"SELECT 1 FROM daily_stocktake "
"WHERE stocktake_date = %s AND warehouse_code = %s AND status = 'finalized' "
"LIMIT 1",
(stocktake_date.strip(), warehouse_code.strip()),
).fetchone()
if dup:
raise ValueError(
f"{stocktake_date} / {warehouse_code} 에 이미 확정된 재고조사가 있습니다."
)
row = conn.execute(
"""
INSERT INTO daily_stocktake
(stocktake_date, warehouse_code, status, memo, created_by)
VALUES (%s, %s, 'draft', %s, %s)
RETURNING *
""",
(
stocktake_date.strip(),
warehouse_code.strip(),
(memo or "").strip(),
(created_by or "").lower().strip(),
),
).fetchone()
return self._serialize(row)
def _assert_draft(self, conn: Any, stocktake_id: int) -> dict[str, Any]:
row = conn.execute(
"SELECT * FROM daily_stocktake WHERE id = %s", (stocktake_id,)
).fetchone()
if not row:
raise KeyError(stocktake_id)
if row["status"] != "draft":
raise ValueError("확정/취소된 재고조사는 라인을 수정할 수 없습니다.")
return row
def upsert_line(
self, *, stocktake_id: int, sku_code: str, qty: int, memo: str = ""
) -> dict[str, Any]:
code = store.validate_stocktake_code(sku_code) # MD- 차단, MY/낱개 허용
n = store.validate_qty(qty, allow_zero=True, allow_negative=False)
with self._pool.connection() as conn:
self._assert_draft(conn, stocktake_id)
row = conn.execute(
"""
INSERT INTO daily_stocktake_line (stocktake_id, sku_code, qty, memo)
VALUES (%s, %s, %s, %s)
ON CONFLICT (stocktake_id, sku_code) DO UPDATE
SET qty = EXCLUDED.qty, memo = EXCLUDED.memo
RETURNING *
""",
(stocktake_id, code, n, (memo or "").strip()),
).fetchone()
return self._serialize(row)
def delete_line(self, *, stocktake_id: int, line_id: int) -> None:
with self._pool.connection() as conn:
self._assert_draft(conn, stocktake_id)
cur = conn.execute(
"DELETE FROM daily_stocktake_line WHERE id = %s AND stocktake_id = %s",
(line_id, stocktake_id),
)
if cur.rowcount == 0:
raise KeyError(line_id)
# ════════════════════════════════════════════════════════════
# 재고조사 랙(rack) 입력 (daily_stocktake_rack)
# 랙 칸 단위로 SKU×입수량×박스수 를 저장하고, 그 집계로
# daily_stocktake_line(SKU별 qty)을 재생성한다(랙이 라인의 단일 출처).
# ════════════════════════════════════════════════════════════
def list_rack_entries(self, *, stocktake_id: int) -> list[dict[str, Any]]:
"""재고조사의 랙 입력 항목 전체. 칸→순서대로 정렬."""
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM daily_stocktake_rack WHERE stocktake_id = %s "
"ORDER BY cell_code ASC, line_no ASC, id ASC",
(stocktake_id,),
).fetchall()
return [self._serialize(r) for r in rows]
def replace_rack(
self, *, stocktake_id: int, entries: list[dict[str, Any]]
) -> dict[str, Any]:
"""랙 입력 전체 교체 → SKU별 집계로 재고조사 라인 재생성.
entries: [{"cell_code","sku_code","units_per_box","box_count"}, ...]
(입력 순서가 line_no 된다.)
트랜잭션:
1) 기존 항목 + 라인 전체 삭제
2) 검증한 항목 삽입(칸별 line_no 부여)
3) aggregate_rack 결과를 daily_stocktake_line 으로 삽입
draft 아니면 ValueError. 없는 /코드도 ValueError.
"""
valid_cells = set(store.rack_cell_codes())
clean: list[tuple[str, str, int, int]] = []
for e in entries:
cell = (e.get("cell_code") or "").strip().upper()
if cell not in valid_cells:
raise ValueError(f"알 수 없는 랙 위치: {cell or '(빈값)'}")
code = store.validate_rack_code(str(e.get("sku_code") or ""))
upb = store.validate_qty(
e.get("units_per_box"), allow_zero=False, allow_negative=False
)
box = store.validate_qty(
e.get("box_count"), allow_zero=False, allow_negative=False
)
clean.append((cell, code, upb, box))
totals = store.aggregate_rack(
[
{"sku_code": c, "units_per_box": u, "box_count": b}
for (_cell, c, u, b) in clean
]
)
cell_seq: dict[str, int] = {}
with self._pool.connection() as conn:
with conn.transaction():
self._assert_draft(conn, stocktake_id)
conn.execute(
"DELETE FROM daily_stocktake_rack WHERE stocktake_id = %s",
(stocktake_id,),
)
conn.execute(
"DELETE FROM daily_stocktake_line WHERE stocktake_id = %s",
(stocktake_id,),
)
for cell, code, upb, box in clean:
ln = cell_seq.get(cell, 0)
cell_seq[cell] = ln + 1
conn.execute(
"""
INSERT INTO daily_stocktake_rack
(stocktake_id, cell_code, line_no, sku_code,
units_per_box, box_count)
VALUES (%s,%s,%s,%s,%s,%s)
""",
(stocktake_id, cell, ln, code, upb, box),
)
for code in sorted(totals.keys()):
qty = totals[code]
if qty <= 0:
continue
conn.execute(
"""
INSERT INTO daily_stocktake_line (stocktake_id, sku_code, qty)
VALUES (%s,%s,%s)
""",
(stocktake_id, code, qty),
)
return {"entries": len(clean), "lines": {k: v for k, v in totals.items() if v > 0}}
def cancel_stocktake(self, *, stocktake_id: int) -> dict[str, Any]:
with self._pool.connection() as conn:
row = conn.execute(
"UPDATE daily_stocktake SET status = 'cancelled' "
"WHERE id = %s AND status <> 'finalized' RETURNING *",
(stocktake_id,),
).fetchone()
if not row:
raise ValueError("확정된 조사는 취소할 수 없거나, 대상이 없습니다.")
return self._serialize(row)
def compute_result(
self, *, stocktake_id: int, bom_map: dict[str, list[dict[str, Any]]]
) -> dict[str, Any]:
"""재고조사 최종 계산 결과(낱개 아이템 기준).
bom_map 라우터가 itemcode 리더에서 받아 주입.
반환:
{
"stocktake": {...헤더...},
"rows": [{item_code,item_name,direct_qty,from_set_qty,total_qty}, ...],
"missing_bom": [MY-... BOM 없는 세트],
}
"""
head = self.get_stocktake(stocktake_id=stocktake_id, with_lines=True)
if not head:
raise KeyError(stocktake_id)
lines = head.get("lines", [])
exploded = store.explode_stocktake(lines, bom_map)
missing = store.missing_bom_sets(lines, bom_map)
names = self.item_name_map()
rows = []
for code in sorted(exploded.keys()):
v = exploded[code]
rows.append(
{
"stocktake_id": stocktake_id,
"stocktake_date": head["stocktake_date"],
"warehouse_code": head["warehouse_code"],
"item_code": code,
"item_name": names.get(code, code),
"direct_qty": v["direct_qty"],
"from_set_qty": v["from_set_qty"],
"total_qty": v["total_qty"],
}
)
return {"stocktake": head, "rows": rows, "missing_bom": missing}
def finalize_stocktake(
self, *, stocktake_id: int, bom_map: dict[str, list[dict[str, Any]]]
) -> dict[str, Any]:
"""재고조사 확정 — 최종 낱개 total_qty 와 현재 시스템 재고의 '차이'
STOCKTAKE movement 생성한다(요구사항 7).
- 차이 = total_qty - current_system_qty qty 부호 그대로 STOCKTAKE 저장
- movement 이력이 남고, 현재고 계산은 단순 합산으로 유지된다.
- BOM 없는 MY- 세트는 확정을 막지 않는다(경고만). 해당 세트는 낱개로
분해되지 않아 낱개 재고에 0 으로 반영된다. BOM itemcode_db
(set_components)에서 등록해야 분해가 적용된다.
반환: {"stocktake": {...}, "adjustments": [{item_code, before, after, diff}], "rows": [...]}
"""
computed = self.compute_result(stocktake_id=stocktake_id, bom_map=bom_map)
head = computed["stocktake"]
if head["status"] != "draft":
raise ValueError("이미 확정/취소된 재고조사입니다.")
if computed["missing_bom"]:
logger.warning(
"재고조사 #%s 확정 — BOM 없는 세트 무시(낱개 0 반영): %s",
stocktake_id,
", ".join(computed["missing_bom"]),
)
wh = head["warehouse_code"]
st_date = head["stocktake_date"]
current = self.current_stock(warehouse_code=wh)
rows = computed["rows"]
adjustments: list[dict[str, Any]] = []
with self._pool.connection() as conn:
with conn.transaction():
for r in rows:
code = r["item_code"]
before = current.get(code, 0)
after = r["total_qty"]
diff = after - before
if diff != 0:
conn.execute(
"""
INSERT INTO stock_movement
(movement_date, warehouse_code, item_code, movement_type,
qty, ref_type, ref_no, memo, created_by)
VALUES (%s,%s,%s,'STOCKTAKE',%s,'stocktake',%s,%s,%s)
""",
(
st_date,
wh,
code,
diff,
str(stocktake_id),
f"재고조사 #{stocktake_id} 반영(차이 {diff:+d})",
head.get("created_by", ""),
),
)
adjustments.append(
{"item_code": code, "before": before, "after": after, "diff": diff}
)
conn.execute(
"UPDATE daily_stocktake SET status = 'finalized', finalized_at = %s "
"WHERE id = %s",
(now_kst(), stocktake_id),
)
head = self.get_stocktake(stocktake_id=stocktake_id, with_lines=True)
return {"stocktake": head, "adjustments": adjustments, "rows": rows}
# ════════════════════════════════════════════════════════════
# 데이터 초기화 (슈퍼관리자 전용 — 라우터에서 권한 검사)
# ════════════════════════════════════════════════════════════
def reset_all_data(self) -> dict[str, int]:
"""전체 창고의 재고 수량 데이터 초기화.
삭제 대상: stock_movement(입고/출고/조정/조사반영) +
set_movement(콤보 출고 원장) +
daily_stocktake/daily_stocktake_line(재고조사).
보존 대상: warehouses, malaysia_items(마스터).
트랜잭션으로 처리. 반환: 삭제 건수.
"""
with self._pool.connection() as conn:
with conn.transaction():
mv = conn.execute("DELETE FROM stock_movement").rowcount or 0
sm = conn.execute("DELETE FROM set_movement").rowcount or 0
ln = conn.execute("DELETE FROM daily_stocktake_line").rowcount or 0
hd = conn.execute("DELETE FROM daily_stocktake").rowcount or 0
return {"movements": mv, "set_movements": sm, "stocktake_lines": ln, "stocktakes": hd}
# ════════════════════════════════════════════════════════════
# 직렬화
# ════════════════════════════════════════════════════════════
@staticmethod
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
for k, v in list(out.items()):
if isinstance(v, datetime):
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
elif isinstance(v, date):
out[k] = v.isoformat()
for k in ("id", "stocktake_id", "qty", "component_qty", "sort_order", "line_no"):
if k in out and out[k] is not None:
try:
out[k] = int(out[k])
except (TypeError, ValueError):
pass
if "active" in out:
out["active"] = bool(out["active"])
return out
+163
View File
@@ -0,0 +1,163 @@
"""itemcode_db 읽기 전용 — 세트 BOM(set_components) 조회.
말레이시아 재고관리는 세트 구성(어느 세트에 어떤 낱개가 ) 별도로
관리하지 않고 itemcode_db 에서 직접 읽는다.
itemcode_db 실제 스키마(운영 확인됨):
set_items(item_code, sabangnet_code, name) -- 세트 마스터
single_items(item_code, sabangnet_code, name) -- 낱개 마스터
set_components(set_code, single_code, quantity) -- 세트 낱개 구성(BOM)
FK: set_code set_items, single_code single_items
환경변수 `ITEMCODE_DB_URL`(읽기 전용 역할 itemcode_ro). cupang 모듈과 공유.
미설정/실패 enabled=False, 결과(/모듈을 죽이지 않음).
"""
from __future__ import annotations
import logging
import os
from typing import Any
logger = logging.getLogger("malaysia.itemcode")
# BOM 구성품으로 인정할 prefix (낱개). MD- 뚜껑은 제외.
_COMPONENT_PREFIXES = ("MT-", "MX-", "MZ-")
class MalaysiaItemcodeReader:
"""itemcode_db 읽기 전용 풀 + 세트 BOM 조회.
설정이 없으면 enabled=False, 메서드는 결과를 돌려준다.
"""
def __init__(self) -> None:
self._pool: Any = 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 미설정 — 세트 BOM 조회 비활성."
return
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 set_bom_map(self) -> dict[str, list[dict[str, Any]]]:
"""MY- 세트의 BOM 전체.
반환: { set_code: [{"component_code","component_qty"}, ...] }
store.explode_stocktake / explode_set 그대로 전달 가능.
구성품은 낱개(MT/MX/MZ) 포함(혹시 모를 비낱개/뚜껑은 제외).
"""
if not self.enabled or not self._pool:
return {}
try:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT set_code, single_code, quantity "
"FROM set_components WHERE set_code LIKE 'MY-%'"
).fetchall()
self.last_error = ""
except Exception as exc: # noqa: BLE001 — 모듈을 죽이지 않음
self.last_error = f"{type(exc).__name__}: {exc}"
logger.exception("itemcode set_components 조회 실패")
return {}
out: dict[str, list[dict[str, Any]]] = {}
for r in rows:
comp = str(r.get("single_code") or "").strip().upper()
if not comp.startswith(_COMPONENT_PREFIXES):
continue
try:
qty = int(r.get("quantity") or 0)
except (TypeError, ValueError):
qty = 0
if qty <= 0:
continue
out.setdefault(str(r["set_code"]).strip().upper(), []).append(
{"component_code": comp, "component_qty": qty}
)
return out
def set_codes_present(self) -> set[str]:
"""set_components 에 등록된 MY- 세트 코드 전체(구성품 유효성 무관).
set_bom_map() 낱개(MT/MX/MZ) 구성품이 하나도 없는 세트를 결과에서
떨어뜨린다. 그래서 'BOM 누락' 가지 원인을 가린다:
(a) set_components set_code 자체가 없음 세트구성 미등록
(b) set_code 있으나 유효 낱개 구성품이 없음 구성품 점검 필요
메서드로 (a)/(b) 구분해 정확한 에러 메시지를 만든다.
"""
if not self.enabled or not self._pool:
return set()
try:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT DISTINCT set_code FROM set_components "
"WHERE set_code LIKE 'MY-%'"
).fetchall()
self.last_error = ""
except Exception as exc: # noqa: BLE001 — 모듈을 죽이지 않음
self.last_error = f"{type(exc).__name__}: {exc}"
logger.exception("itemcode set_components set_code 조회 실패")
return set()
return {str(r["set_code"]).strip().upper() for r in rows}
def name_map(self) -> dict[str, str]:
"""낱개+세트 코드 → 이름(itemcode_db 기준). 표시 보강용(선택)."""
if not self.enabled or not self._pool:
return {}
try:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT item_code, name FROM single_items "
"UNION ALL SELECT item_code, name FROM set_items"
).fetchall()
self.last_error = ""
except Exception as exc: # noqa: BLE001
self.last_error = f"{type(exc).__name__}: {exc}"
return {}
return {
str(r["item_code"]).strip().upper(): str(r.get("name") or "").strip()
for r in rows
}
def sabangnet_code_map(self) -> dict[str, str]:
"""낱개+세트 item_code → 사방넷 코드(itemcode_db). 엑셀 내보내기용."""
if not self.enabled or not self._pool:
return {}
try:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT item_code, sabangnet_code FROM single_items "
"UNION ALL SELECT item_code, sabangnet_code FROM set_items"
).fetchall()
self.last_error = ""
except Exception as exc: # noqa: BLE001
self.last_error = f"{type(exc).__name__}: {exc}"
return {}
return {
str(r["item_code"]).strip().upper(): str(r.get("sabangnet_code") or "").strip()
for r in rows
}
def close(self) -> None:
if self._pool is not None:
self._pool.close()
+985
View File
@@ -0,0 +1,985 @@
"""말레이시아 창고 재고관리 모듈 라우터.
- 경로: /malaysia
- 권한: 로그인 + `malaysia` 모듈 권한 (관리자는 항상 통과). 서버 검사.
- 데이터: MalaysiaStockStore (malaysia_stock_db / PostgreSQL) 전용.
MALAYSIA_STOCK_DB_URL 미설정 store None 이며, 페이지는 "설정 필요" 안내.
- 세트 BOM: itemcode_db(set_components) 읽기 전용(MalaysiaItemcodeReader). 별도 관리 메뉴 없음.
입력 방식: 입출고/재고조사 모두 전체 아이템(+세트) 그리드에 수량만 키인 일괄 등록.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
from app.timezone import today_kst
from . import store
router = APIRouter(prefix="/malaysia", tags=["malaysia"])
# ────────────────────────────────────────────────────────────
# 공용 헬퍼
# ────────────────────────────────────────────────────────────
def _store(request: Request) -> Any:
return getattr(request.app.state, "malaysia_store", None)
def _reader(request: Request) -> Any:
return getattr(request.app.state, "malaysia_itemcode", None)
def _bom_map(request: Request) -> dict[str, list[dict[str, Any]]]:
"""itemcode_db(set_components)에서 MY- 세트 BOM 을 읽어 반환. 리더 없으면 {}."""
r = _reader(request)
return r.set_bom_map() if r else {}
_WEEKDAYS_KO = ("", "", "", "", "", "", "")
def _date_label(iso: str | None) -> str:
"""'2026-06-11''2026-06-11 (목)'. 파싱 실패 시 원문."""
from datetime import date as _date # noqa: WPS433
s = (iso or "").strip()
try:
d = _date.fromisoformat(s[:10])
except (ValueError, TypeError):
return s
return f"{d.isoformat()} ({_WEEKDAYS_KO[d.weekday()]})"
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, "malaysia"):
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": "말레이시아 재고관리 모듈이 아직 설정되지 않았습니다. "
"MALAYSIA_STOCK_DB_URL 환경변수를 설정하고 "
"scripts/sql/malaysia_stock_db_init.sql 로 malaysia_stock_db 를 "
"초기화한 뒤 컨테이너를 재기동하세요.",
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="malaysia"),
},
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, "malaysia"):
return render_template(
request,
"denied.html",
{"reason": "말레이시아 재고관리 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)},
status_code=403,
)
st = _store(request)
if st is None:
return _render_config_needed(request, user)
return st, user
def _selected_wh(request: Request, st: Any) -> str:
"""쿼리 ?wh= 또는 기본(첫 활성 창고)."""
wh = (request.query_params.get("wh") or "").strip()
codes = [w["warehouse_code"] for w in st.list_warehouses()]
if wh in codes:
return wh
return codes[0] if codes else "MY-WH-01"
def _base_ctx(request: Request, user: dict[str, Any], st: Any, *, wh: str) -> dict[str, Any]:
from app.main import build_erp_nav # noqa: WPS433
from app.store import is_admin # noqa: WPS433
reader = _reader(request)
ko_names = reader.name_map() if reader else {}
return {
"user": user,
"is_admin": is_admin(user),
"is_super": bool(user.get("is_super_admin")),
"nav_items": build_erp_nav(user, active="malaysia"),
"warehouses": st.list_warehouses(),
"selected_wh": wh,
"ko_names": ko_names,
}
# ════════════════════════════════════════════════════════════
# 재고 현황 (메인)
# ════════════════════════════════════════════════════════════
@router.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
st, user = guard
wh = _selected_wh(request, st)
ctx = _base_ctx(request, user, st, wh=wh)
rows = st.stock_status(warehouse_code=wh)
# 마지막 재고조사(취소 제외) 의 낱개 기준 total 과 전산재고 차이
latest = st.latest_stocktake(warehouse_code=wh)
last_date = latest["stocktake_date"] if latest else None
last_map: dict[str, int] = {}
if latest:
comp = st.compute_result(stocktake_id=latest["id"], bom_map=_bom_map(request))
last_map = {r["item_code"]: r["total_qty"] for r in comp["rows"]}
for r in rows:
code = r["item_code"]
lq = last_map.get(code)
r["last_stocktake_date"] = last_date
r["last_qty"] = lq
r["diff"] = (lq - r["current_qty"]) if lq is not None else None
set_stock = st.latest_set_quantities(warehouse_code=wh)
ctx.update(
{
"page_title": "말레이시아 재고관리",
"page_subtitle": f"{wh} · 재고 현황",
"rows": rows,
"set_rows": set_stock["rows"],
"set_stocktake_date": set_stock["stocktake_date"],
}
)
return render_template(request, "malaysia/index.html", ctx)
@router.get("/rack", response_class=HTMLResponse)
async def rack_view(request: Request) -> HTMLResponse:
"""창고 랙 보기 — 최근(취소 제외) 재고조사의 랙 입력을 칸별로 한눈에.
읽기 전용. 칸에 어떤 상품이 박스(× 입수량 = 총개수) 있는지 표시.
"""
from app.main import render_template # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
st, user = guard
wh = _selected_wh(request, st)
# 랙보기는 '랙을 가장 최근에 저장한' 조사 기준(조사 날짜 아님).
latest = st.latest_rack_stocktake(warehouse_code=wh)
rack_by_cell: dict[str, list[dict[str, Any]]] = {}
# 뚜껑(MD-)은 malaysia_items 에 없으므로 이름맵에 병합해 코드 대신 이름 표시.
names = {**st.item_name_map(), **store.lid_name_map()}
if latest:
for e in st.list_rack_entries(stocktake_id=latest["id"]):
e["item_name"] = names.get(e["sku_code"], e["sku_code"])
e["total"] = int(e["units_per_box"]) * int(e["box_count"])
rack_by_cell.setdefault(e["cell_code"], []).append(e)
ctx = _base_ctx(request, user, st, wh=wh)
ctx.update(
{
"page_title": "말레이시아 재고관리 — 창고 랙",
"page_subtitle": f"{wh} · 랙 보기",
"rack_layout": store.rack_layout(),
"rack_by_cell": rack_by_cell,
"stocktake": latest,
}
)
return render_template(request, "malaysia/rack_view.html", ctx)
@router.post("/rack/save")
async def rack_save(
request: Request, user: dict[str, Any] = Depends(_require_user)
) -> RedirectResponse:
"""랙 보기에서 드래그로 재배치한 칸 구성을 저장(위치만 변경).
대상은 보기가 보여주는 조사(latest_rack_stocktake). 수량 합계는
불변이며 확정본도 허용. 폼은 stocktake_detail 동일한 병렬배열
(rk_cell/rk_sku/rk_upb/rk_box) _parse_rack_form 재사용.
"""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
form = await request.form()
wh = str(form.get("warehouse_code") or "").strip() or _selected_wh(request, st)
latest = st.latest_rack_stocktake(warehouse_code=wh)
if not latest:
raise HTTPException(status_code=400, detail="저장할 랙 재고조사가 없습니다.")
entries = _parse_rack_form(form)
try:
st.reposition_rack(stocktake_id=latest["id"], entries=entries)
except (KeyError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/malaysia/rack?wh={wh}", status_code=303)
@router.post("/reset")
async def reset_data(
request: Request, user: dict[str, Any] = Depends(_require_user)
) -> RedirectResponse:
"""전체 창고 재고 수량 데이터 초기화 — 슈퍼관리자 전용.
stock_movement + daily_stocktake(+line) 전체 삭제(현재고/조사값 0).
마스터(warehouses, malaysia_items) 보존.
"""
if not user.get("is_super_admin"):
raise HTTPException(status_code=403, detail="재고 데이터 초기화는 슈퍼관리자만 가능합니다.")
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
st.reset_all_data()
return RedirectResponse(url="/malaysia/", status_code=303)
# ════════════════════════════════════════════════════════════
# 입고 / 출고 등록 (그리드 일괄)
# ════════════════════════════════════════════════════════════
@router.get("/movements", response_class=HTMLResponse)
async def movements_page(request: Request) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
st, user = guard
wh = _selected_wh(request, st)
mtype = (request.query_params.get("type") or "").strip().upper()
fdate = (request.query_params.get("date") or "").strip()
mt = mtype if mtype in store.MOVEMENT_TYPES else None
raw_singles = st.list_movements(
warehouse_code=wh, movement_type=mt,
date_from=fdate or None, date_to=fdate or None, limit=300,
)
# 콤보 분해로 생긴 구성 낱개(ref_type=SET_COMPONENT_REF)는 '낱개 입력'이 아니므로
# 이동이력에서 숨긴다. 콤보는 아래 set_movement 줄로 표시된다.
singles = [m for m in raw_singles if (m.get("ref_type") or "") != store.SET_COMPONENT_REF]
for m in singles:
m["kind"] = "individual"
# 콤보(세트) 출고 원장도 함께 보여준다(낱개/콤보 분리 표시).
combos = st.list_set_movements(
warehouse_code=wh, movement_type=mt,
date_from=fdate or None, date_to=fdate or None, limit=300,
)
for m in combos:
m["kind"] = "set"
m["item_code"] = m.get("set_code", "")
# 날짜(그다음 id) 내림차순 병합.
movements = sorted(
singles + combos,
key=lambda m: (str(m.get("movement_date") or ""), int(m.get("id") or 0)),
reverse=True,
)
for m in movements:
m["date_label"] = _date_label(m.get("movement_date"))
ctx = _base_ctx(request, user, st, wh=wh)
ctx.update(
{
"page_title": "말레이시아 재고관리 — 입출고",
"page_subtitle": f"{wh} · 입고/출고/조정 일괄 등록",
"individual_items": st.list_items(kind="individual"),
"set_items": st.list_items(kind="set"),
"movements": movements,
"today": today_kst().isoformat(),
"filter_type": mtype if mtype in store.MOVEMENT_TYPES else "",
"filter_date": fdate,
}
)
return render_template(request, "malaysia/movements.html", ctx)
@router.post("/movements/apply")
async def movements_apply(request: Request, user: dict[str, Any] = Depends(_require_user)) -> RedirectResponse:
"""낱개 + 세트 한 화면 일괄 적용 (버튼 하나).
form: movement_type(낱개용 IN/OUT/ADJUST), movement_date, warehouse_code,
ref_type, ref_no, memo, qty_<CODE>...
- 낱개(individual): 선택한 movement_type 으로 등록. IN/OUT 양수만, ADJUST ±(0 스킵).
- 세트(set): 항상 OUT 으로 처리 BOM(itemcode_db) 분해해 구성품별 OUT 생성.
콤보는 입고 불가(낱개로 분리 입고). IN 선택 + 콤보 수량 입력 400 안내.
빈칸/0 스킵. 코드별 에러는 모아서 부분 적용 안내.
"""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
form = await request.form()
mtype = str(form.get("movement_type") or "").strip().upper()
if mtype not in ("IN", "OUT", "ADJUST"):
raise HTTPException(status_code=400, detail="이동 종류 오류")
wh = str(form.get("warehouse_code") or "").strip()
mdate = str(form.get("movement_date") or "").strip()
ref_type = str(form.get("ref_type") or "").strip()
ref_no = str(form.get("ref_no") or "").strip()
memo = str(form.get("memo") or "").strip()
bmap = _bom_map(request)
# 콤보는 입고 불가 — IN 선택 + 세트 수량 입력 시 차단(낱개로 분리 입고 안내)
if mtype == "IN":
set_codes_keyed = [
it["item_code"]
for it in st.list_items(kind="set")
if str(form.get(f"qty_{it['item_code']}") or "").strip() not in ("", "0")
]
if set_codes_keyed:
raise HTTPException(
status_code=400,
detail="콤보 상품은 입고할 수 없습니다. 낱개로 분리해서 입고하세요. "
"(입고 불가 콤보: " + ", ".join(set_codes_keyed) + ")",
)
applied = 0
errors: list[str] = []
# 낱개 — 선택한 종류로
for it in st.list_items(kind="individual"):
code = it["item_code"]
raw = str(form.get(f"qty_{code}") or "").strip()
if raw == "":
continue
try:
n = int(raw)
except ValueError:
errors.append(f"{code}: 숫자 아님")
continue
if n == 0:
continue
try:
st.create_movement(
movement_date=mdate, warehouse_code=wh, item_code=code,
movement_type=mtype, qty=n, ref_type=ref_type, ref_no=ref_no,
memo=memo, created_by=user["email"],
)
applied += 1
except ValueError as exc:
errors.append(f"{code}: {exc}")
# 세트 — 항상 OUT(BOM 분해). 콤보 입고는 위에서 이미 차단됨.
for it in st.list_items(kind="set"):
code = it["item_code"]
raw = str(form.get(f"qty_{code}") or "").strip()
if raw == "":
continue
try:
n = int(raw)
except ValueError:
errors.append(f"{code}: 숫자 아님")
continue
if n <= 0:
continue
try:
st.create_set_out(
movement_date=mdate, warehouse_code=wh, set_code=code, set_qty=n,
bom_map=bmap, ref_type="set", ref_no=ref_no, memo=memo,
created_by=user["email"],
)
applied += 1
except ValueError as exc:
errors.append(f"{code}: {exc}")
if errors and applied == 0:
raise HTTPException(status_code=400, detail="; ".join(errors[:10]))
return RedirectResponse(url=f"/malaysia/movements?wh={wh}&type={mtype}", status_code=303)
@router.get("/movements/export")
async def movements_export(request: Request) -> StreamingResponse:
"""해당 일자/종류(IN/OUT)에 입출고된 낱개 수량을 엑셀로 내보낸다.
컬럼: A=사방넷 코드, B=수량, C=이름.
세트 OUT 생성 구성품 OUT 으로 분해 저장되므로 낱개 기준으로 합산된다.
"""
import io # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard # type: ignore[return-value]
st, _user = guard
wh = _selected_wh(request, st)
mtype = (request.query_params.get("type") or "OUT").strip().upper()
if mtype not in ("IN", "OUT"):
raise HTTPException(status_code=400, detail="type 은 IN 또는 OUT 이어야 합니다.")
mdate = (request.query_params.get("date") or today_kst().isoformat()).strip()
totals = st.daily_movement_totals(
warehouse_code=wh, movement_date=mdate, movement_type=mtype
)
reader = _reader(request)
sabang = reader.sabangnet_code_map() if reader else {}
names = reader.name_map() if reader else {}
item_names = st.item_name_map()
from openpyxl import Workbook # noqa: WPS433
from openpyxl.styles import Alignment, Font, PatternFill # noqa: WPS433
from openpyxl.utils import get_column_letter # noqa: WPS433
wb = Workbook()
ws = wb.active
ws.title = f"{mtype}_{mdate}"
header = ["사방넷코드", "수량", "이름"]
ws.append(header)
for code in sorted(totals.keys()):
qty = totals[code]
if qty == 0:
continue
ws.append([
sabang.get(code, ""),
int(qty),
names.get(code) or item_names.get(code) or code,
])
# 제목행(A1~C1): 파랑 배경 + 흰색 굵은글씨 + 가운데 정렬
fill = PatternFill(start_color="FF4472C4", end_color="FF4472C4", fill_type="solid")
font = Font(color="FFFFFFFF", bold=True)
center = Alignment(horizontal="center", vertical="center")
for col in range(1, len(header) + 1):
cell = ws.cell(row=1, column=col)
cell.fill = fill
cell.font = font
cell.alignment = center
# 열너비 자동(내용 길이 기준, 한글 폭 보정). 최소 8 ~ 최대 50.
for col in range(1, len(header) + 1):
letter = get_column_letter(col)
longest = 0
for cell in ws[letter]:
v = "" if cell.value is None else str(cell.value)
width = sum(2 if ord(ch) > 0x1100 else 1 for ch in v)
longest = max(longest, width)
ws.column_dimensions[letter].width = max(8, min(50, longest + 2))
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
fname = f"malaysia_{mtype}_{mdate}.xlsx"
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
@router.get("/rack/export")
async def rack_export(request: Request) -> StreamingResponse:
"""창고 랙 보기 데이터를 엑셀로 내보낸다.
보기와 동일 기준(latest_rack_stocktake) 모든 항목을 행씩.
컬럼: 아이템 코드 / 이름 / 수량 / 랙번호. 전체에 자동필터,
열은 내용 길이에 맞춰 너비 자동.
"""
import io # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard # type: ignore[return-value]
st, _user = guard
wh = _selected_wh(request, st)
latest = st.latest_rack_stocktake(warehouse_code=wh)
names = {**st.item_name_map(), **store.lid_name_map()}
rows: list[tuple[str, str, int, str]] = []
if latest:
for e in st.list_rack_entries(stocktake_id=latest["id"]):
code = e["sku_code"]
qty = int(e["units_per_box"]) * int(e["box_count"])
rows.append((code, names.get(code, code), qty, e["cell_code"]))
# 랙번호 → 아이템코드 순 정렬(보기 흐름과 동일하게 칸 기준).
rows.sort(key=lambda r: (r[3], r[0]))
from openpyxl import Workbook # noqa: WPS433
from openpyxl.styles import Alignment, Font, PatternFill # noqa: WPS433
from openpyxl.utils import get_column_letter # noqa: WPS433
# 아이템별 합계(코드 기준 수량 합산). 코드→이름 보존, 코드순 정렬.
summary: dict[str, int] = {}
for code, _name, qty, _cell in rows:
summary[code] = summary.get(code, 0) + qty
summary_rows = sorted(
((code, names.get(code, code), total) for code, total in summary.items()),
key=lambda r: r[0],
)
fill = PatternFill(start_color="FF4472C4", end_color="FF4472C4", fill_type="solid")
font = Font(color="FFFFFFFF", bold=True)
center = Alignment(horizontal="center", vertical="center")
def _style_header(row_idx: int, ncols: int) -> None:
for col in range(1, ncols + 1):
cell = ws.cell(row=row_idx, column=col)
cell.fill = fill
cell.font = font
cell.alignment = center
wb = Workbook()
ws = wb.active
ws.title = "rack"
# ── 상세 표 ──
header = ["아이템 코드", "이름", "수량", "랙번호"]
ws.append(header)
for code, name, qty, cell in rows:
ws.append([code, name, qty, cell])
_style_header(1, len(header))
detail_last = ws.max_row # 자동필터 범위(상세표만)
ws.auto_filter.ref = f"A1:{get_column_letter(len(header))}{detail_last}"
# ── 아이템별 합계 표 (상세표 아래, 한 줄 띄움) ──
# 빈 셀 행으로 한 줄 띄움(append([]) 은 셀이 없어 max_row 가 안 늘어 위치가
# 어긋나므로 빈 문자열 셀을 넣는다).
ws.append([""])
sum_header = ["아이템 코드", "이름", "합계 수량"]
ws.append(sum_header)
sum_header_row = ws.max_row # 방금 추가한 합계 제목행(셀이 있어 정확)
for code, name, total in summary_rows:
ws.append([code, name, total])
_style_header(sum_header_row, len(sum_header))
# 열너비 자동(내용 길이 기준, 한글 폭 보정). 최소 8 ~ 최대 50.
for col in range(1, len(header) + 1):
letter = get_column_letter(col)
longest = 0
for cell in ws[letter]:
v = "" if cell.value is None else str(cell.value)
width = sum(2 if ord(ch) > 0x1100 else 1 for ch in v)
longest = max(longest, width)
ws.column_dimensions[letter].width = max(8, min(50, longest + 2))
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
sdate = (latest or {}).get("stocktake_date", today_kst().isoformat())
fname = f"malaysia_rack_{wh}_{sdate}.xlsx"
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
# ════════════════════════════════════════════════════════════
# 일일 재고조사
# ════════════════════════════════════════════════════════════
@router.get("/stocktakes", response_class=HTMLResponse)
async def stocktakes_page(request: Request) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
st, user = guard
wh = _selected_wh(request, st)
ctx = _base_ctx(request, user, st, wh=wh)
ctx.update(
{
"page_title": "말레이시아 재고관리 — 일일 재고조사",
"page_subtitle": f"{wh} · 조사 목록",
"stocktakes": st.list_stocktakes(warehouse_code=wh),
"today": today_kst().isoformat(),
}
)
return render_template(request, "malaysia/stocktakes.html", ctx)
@router.post("/stocktakes")
async def stocktake_create(
request: Request,
stocktake_date: str = Form(...),
warehouse_code: str = Form(...),
memo: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
try:
head = st.create_stocktake(
stocktake_date=stocktake_date, warehouse_code=warehouse_code,
memo=memo, created_by=user["email"],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/malaysia/stocktakes/{head['id']}", status_code=303)
@router.get("/stocktakes/{stocktake_id:int}", response_class=HTMLResponse)
async def stocktake_detail(request: Request, stocktake_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
st, user = guard
try:
computed = st.compute_result(stocktake_id=stocktake_id, bom_map=_bom_map(request))
except KeyError:
return render_template(
request, "denied.html",
{"reason": "재고조사를 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
head = computed["stocktake"]
wh = head["warehouse_code"]
# 입력된 라인 qty 를 코드별 dict 로 (그리드 기본값)
entered = {ln["sku_code"]: ln["qty"] for ln in head.get("lines", [])}
# 랙 입력 항목을 칸별로 묶어 전달(그림 안 입력행 복원용)
rack_by_cell: dict[str, list[dict[str, Any]]] = {}
for e in st.list_rack_entries(stocktake_id=stocktake_id):
rack_by_cell.setdefault(e["cell_code"], []).append(e)
is_draft = head["status"] == "draft"
# 작성중일 때만 '이전값 불러오기' 가능 여부 계산(직전 조사에 랙 입력이 있을 때)
has_prev_rack = bool(
is_draft
and st.previous_rack_entries(warehouse_code=wh, exclude_stocktake_id=stocktake_id)
)
ctx = _base_ctx(request, user, st, wh=wh)
ctx.update(
{
"page_title": f"재고조사 #{stocktake_id}",
"page_subtitle": f"{head['stocktake_date']} · {wh} · {head['status']}",
"stocktake": head,
"rack_layout": store.rack_layout(),
"rack_by_cell": rack_by_cell,
# 낱개·세트 + 뚜껑(MD-, 위치확인용). 뚜껑은 재고 집계엔 빠진다.
"rack_items": st.list_items() + list(store.LID_ITEMS),
"entered": entered,
"result_rows": computed["rows"],
"missing_bom": computed["missing_bom"],
"is_draft": is_draft,
"has_prev_rack": has_prev_rack,
}
)
return render_template(request, "malaysia/stocktake_detail.html", ctx)
@router.post("/stocktakes/{stocktake_id:int}/lines/bulk")
async def stocktake_lines_bulk(
request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user)
) -> RedirectResponse:
"""그리드 일괄 저장. form: qty_<SKUCODE>... (낱개+세트). 빈칸은 스킵, 0 허용."""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
form = await request.form()
codes = [i["item_code"] for i in st.list_items(kind="individual")]
codes += [i["item_code"] for i in st.list_items(kind="set")]
errors: list[str] = []
for code in codes:
raw = str(form.get(f"qty_{code}") or "").strip()
if raw == "":
continue
try:
n = int(raw)
except ValueError:
errors.append(f"{code}: 숫자 아님")
continue
try:
st.upsert_line(stocktake_id=stocktake_id, sku_code=code, qty=n)
except KeyError:
raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.")
except ValueError as exc:
errors.append(f"{code}: {exc}")
if errors:
raise HTTPException(status_code=400, detail="; ".join(errors[:10]))
return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303)
def _parse_rack_form(form: Any) -> list[dict[str, Any]]:
"""랙 폼 병렬배열(rk_cell/rk_sku/rk_upb/rk_box) → 입력 항목 리스트.
SKU 미선택/입수량·박스수 0 행은 스킵(미입력 취급).
"""
cells = form.getlist("rk_cell")
skus = form.getlist("rk_sku")
upbs = form.getlist("rk_upb")
boxes = form.getlist("rk_box")
entries: list[dict[str, Any]] = []
for cell, sku, upb, box in zip(cells, skus, upbs, boxes):
code = str(sku or "").strip()
if not code:
continue
try:
u = int(str(upb).strip() or 0)
b = int(str(box).strip() or 0)
except ValueError:
continue
if u <= 0 or b <= 0:
continue
entries.append(
{"cell_code": cell, "sku_code": code, "units_per_box": u, "box_count": b}
)
return entries
@router.post("/stocktakes/{stocktake_id:int}/rack/bulk")
async def stocktake_rack_bulk(
request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user)
) -> RedirectResponse:
"""랙 입력 일괄 저장.
저장 항목 교체 + SKU별 집계로 daily_stocktake_line 재생성.
"""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
form = await request.form()
entries = _parse_rack_form(form)
try:
st.replace_rack(stocktake_id=stocktake_id, entries=entries)
except KeyError:
raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303)
@router.post("/stocktakes/{stocktake_id:int}/rack/load-previous")
async def stocktake_rack_load_previous(
request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user)
) -> RedirectResponse:
"""직전 재고조사의 랙 저장값을 현재(작성중) 조사로 불러와 채운다.
이전 항목을 그대로 replace_rack 으로 적용 화면에 복원되어 표시된다.
사용자는 이어서 수정 저장/확정한다. 현재 입력은 덮어쓰므로 화면에서 confirm.
"""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
head = st.get_stocktake(stocktake_id=stocktake_id, with_lines=False)
if head is None:
raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.")
if head["status"] != "draft":
raise HTTPException(status_code=400, detail="작성중인 조사만 불러올 수 있습니다.")
entries = st.previous_rack_entries(
warehouse_code=head["warehouse_code"], exclude_stocktake_id=stocktake_id
)
if not entries:
raise HTTPException(status_code=400, detail="불러올 이전 재고조사 랙 데이터가 없습니다.")
try:
st.replace_rack(stocktake_id=stocktake_id, entries=entries)
except (KeyError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303)
@router.post("/stocktakes/{stocktake_id:int}/rack/finalize")
async def stocktake_rack_finalize(
request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user)
) -> RedirectResponse:
"""랙 입력 저장 + 즉시 확정(한 번에).
확정 버튼이 폼과 별개라 데이터가 누락되는 문제를 막기 위해,
화면의 입력을 먼저 저장(replace_rack) finalize 한다.
"""
from app.store import is_admin # noqa: WPS433
if not is_admin(user):
raise HTTPException(status_code=403, detail="전산 재고 반영은 관리자만 가능합니다.")
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
form = await request.form()
entries = _parse_rack_form(form)
try:
st.replace_rack(stocktake_id=stocktake_id, entries=entries)
st.finalize_stocktake(stocktake_id=stocktake_id, bom_map=_bom_map(request))
except KeyError:
raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303)
@router.post("/stocktakes/{stocktake_id:int}/lines/{line_id:int}/delete")
async def stocktake_line_delete(
request: Request, stocktake_id: int, line_id: int,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
try:
st.delete_line(stocktake_id=stocktake_id, line_id=line_id)
except KeyError:
raise HTTPException(status_code=404, detail="라인을 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303)
@router.post("/stocktakes/{stocktake_id:int}/finalize")
async def stocktake_finalize(
request: Request, stocktake_id: int, 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="전산 재고 반영은 관리자만 가능합니다.")
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
try:
st.finalize_stocktake(stocktake_id=stocktake_id, bom_map=_bom_map(request))
except KeyError:
raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303)
@router.post("/stocktakes/{stocktake_id:int}/cancel")
async def stocktake_cancel(
request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user)
) -> RedirectResponse:
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
try:
st.cancel_stocktake(stocktake_id=stocktake_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303)
@router.post("/stocktakes/{stocktake_id:int}/delete")
async def stocktake_delete(
request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user)
) -> RedirectResponse:
"""재고조사 완전 삭제 — 슈퍼관리자 전용."""
if not user.get("is_super_admin"):
raise HTTPException(status_code=403, detail="재고조사 삭제는 슈퍼관리자만 가능합니다.")
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
wh = _selected_wh(request, st)
try:
st.delete_stocktake(stocktake_id=stocktake_id)
except KeyError:
raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.")
return RedirectResponse(url=f"/malaysia/stocktakes?wh={wh}", status_code=303)
# ════════════════════════════════════════════════════════════
# JSON API
# ════════════════════════════════════════════════════════════
@router.get("/api/warehouses")
async def api_warehouses(request: Request, _: dict[str, Any] = Depends(_require_user)) -> JSONResponse:
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
return JSONResponse({"warehouses": st.list_warehouses()})
@router.get("/api/items")
async def api_items(request: Request, kind: str = "", _: dict[str, Any] = Depends(_require_user)) -> JSONResponse:
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
k = kind if kind in ("individual", "set") else None
return JSONResponse({"items": st.list_items(kind=k)})
@router.get("/api/bom")
async def api_bom(request: Request, _: dict[str, Any] = Depends(_require_user)) -> JSONResponse:
"""itemcode_db(set_components)에서 읽은 MY- 세트 BOM (읽기 전용)."""
return JSONResponse({"bom": _bom_map(request)})
@router.get("/api/stock")
async def api_stock(request: Request, wh: str = "", _: dict[str, Any] = Depends(_require_user)) -> JSONResponse:
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
wh = wh.strip() or _selected_wh(request, st)
return JSONResponse({"warehouse_code": wh, "rows": st.stock_status(warehouse_code=wh)})
@router.post("/api/movements")
async def api_movement_create(
request: Request, payload: dict[str, Any] = Body(...), user: dict[str, Any] = Depends(_require_user)
) -> JSONResponse:
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
try:
row = st.create_movement(
movement_date=str(payload.get("movement_date") or ""),
warehouse_code=str(payload.get("warehouse_code") or ""),
item_code=str(payload.get("item_code") or ""),
movement_type=str(payload.get("movement_type") or ""),
qty=int(payload.get("qty") or 0),
ref_type=str(payload.get("ref_type") or ""),
ref_no=str(payload.get("ref_no") or ""),
memo=str(payload.get("memo") or ""),
created_by=user["email"],
)
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"ok": True, "movement": row})
@router.post("/api/movements/set-out")
async def api_set_out(
request: Request, payload: dict[str, Any] = Body(...), user: dict[str, Any] = Depends(_require_user)
) -> JSONResponse:
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
try:
rows = st.create_set_out(
movement_date=str(payload.get("movement_date") or ""),
warehouse_code=str(payload.get("warehouse_code") or ""),
set_code=str(payload.get("set_code") or ""),
set_qty=int(payload.get("set_qty") or 0),
bom_map=_bom_map(request),
ref_type=str(payload.get("ref_type") or "set"),
ref_no=str(payload.get("ref_no") or ""),
memo=str(payload.get("memo") or ""),
created_by=user["email"],
)
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"ok": True, "movements": rows})
@router.get("/api/stocktakes/{stocktake_id:int}/result")
async def api_stocktake_result(
request: Request, stocktake_id: int, _: dict[str, Any] = Depends(_require_user)
) -> JSONResponse:
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
try:
computed = st.compute_result(stocktake_id=stocktake_id, bom_map=_bom_map(request))
except KeyError:
raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.")
return JSONResponse(
{"stocktake": computed["stocktake"], "rows": computed["rows"], "missing_bom": computed["missing_bom"]}
)
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "module": "malaysia"}
+335
View File
@@ -0,0 +1,335 @@
"""말레이시아 창고 재고관리 모듈 — 상수 및 순수 계산/검증 헬퍼.
- 데이터 저장은 malaysia_stock_db(PostgreSQL) 전용이다(`db.py`).
운영 데이터가 JSON DB 갈라지는 것을 막기 위해 JSON 폴백을 두지 않는다.
MALAYSIA_STOCK_DB_URL 미설정 라우터가 "설정 필요" 안내 페이지를 보여준다.
- 모듈에는 DB 의존이 없는 상수와 순수 함수만 둔다(테스트 용이).
prefix 검증, 세트 BOM 분해, 재고조사 집계가 모두 여기 있다.
코드 prefix 규칙(요구사항 8 검증):
- 낱개(개별) 아이템 : MT-, MX-, MZ- 입고/출고/조정 movement 가능
- 세트 아이템 : MY- movement 불가, 재고조사/BOM 가능
- 뚜껑(제외) : MD- 어디에도 사용 불가
"""
from __future__ import annotations
from typing import Any, Iterable
# ── 이동(movement) 종류 ──
MOVEMENT_TYPES: tuple[str, ...] = ("IN", "OUT", "ADJUST", "STOCKTAKE")
# 콤보(세트) 출고를 BOM 분해해 생성한 '구성 낱개' movement 의 ref_type 마커.
# 이동이력에서 이 낱개 줄은 콤보 입력의 분해분이므로 '낱개 입력'으로 표시하지
# 않고 숨긴다(콤보는 set_movement 줄로 따로 표시). 재고 계산엔 그대로 포함된다.
SET_COMPONENT_REF: str = "set"
# ── 재고조사 상태 ──
STOCKTAKE_STATUSES: tuple[str, ...] = ("draft", "finalized", "cancelled")
# ── 코드 prefix ──
INDIVIDUAL_PREFIXES: tuple[str, ...] = ("MT-", "MX-", "MZ-")
SET_PREFIX: str = "MY-"
BLOCKED_PREFIX: str = "MD-" # 뚜껑 — 재고(입출고/조사 집계) 대상에서 제외
# 뚜껑(MD-) 아이템 목록 — 재고에는 반영하지 않고 '창고 랙 위치 확인' 용도로만
# 랙 칸에 배치할 수 있다(드롭다운 노출). malaysia_items 테이블엔 넣지 않는다.
LID_ITEMS: tuple[dict[str, str], ...] = (
{"item_code": "MD-0000", "item_name": "Lid XS", "kind": "lid"},
{"item_code": "MD-0001", "item_name": "Lid S", "kind": "lid"},
{"item_code": "MD-0002", "item_name": "Lid M", "kind": "lid"},
{"item_code": "MD-0003", "item_name": "Lid L", "kind": "lid"},
{"item_code": "MD-0005", "item_name": "Lid 5", "kind": "lid"},
)
def lid_name_map() -> dict[str, str]:
"""뚜껑 코드 → 이름(랙 보기 표시용)."""
return {it["item_code"]: it["item_name"] for it in LID_ITEMS}
# ── 창고 랙 레이아웃 (재고조사 랙 입력용) ──
# 그림 고정: A/B 두 면, 각 면 3행 × 3열, 각 열은 2분할 → 면당 18칸, 총 36칸.
# 행은 위→아래로 3,2,1 (그림과 동일). 칸 코드: '{면}{행}-{열}-{분할}' 예) A3-1-1.
RACK_SIDES: tuple[str, ...] = ("A", "B")
RACK_ROWS: tuple[int, ...] = (3, 2, 1)
RACK_COLS: tuple[int, ...] = (1, 2, 3)
RACK_SUBS: tuple[int, ...] = (1, 2)
# Side B 아래 바닥(Ground) 파렛트 6칸. 코드: 'G1' ~ 'G6'.
RACK_GROUND: tuple[str, ...] = ("G1", "G2", "G3", "G4", "G5", "G6")
def rack_cell_codes() -> list[str]:
"""모든 랙 칸 코드 목록(검증용). 예: 'A3-1-1' ... 'B1-3-2', 'G1'~'G6'."""
codes = [
f"{side}{row}-{col}-{sub}"
for side in RACK_SIDES
for row in RACK_ROWS
for col in RACK_COLS
for sub in RACK_SUBS
]
codes.extend(RACK_GROUND)
return codes
def rack_layout() -> list[dict[str, Any]]:
"""템플릿 렌더용 중첩 구조.
[{"side":"A","rows":[{"row":3,"cols":[["A3-1-1","A3-1-2"], ...]}, ...]}, ...]
"""
out: list[dict[str, Any]] = []
for side in RACK_SIDES:
rows: list[dict[str, Any]] = []
for row in RACK_ROWS:
cols = [
[f"{side}{row}-{col}-{sub}" for sub in RACK_SUBS]
for col in RACK_COLS
]
rows.append({"row": row, "cols": cols})
out.append({"side": side, "label": f"Side {side}", "rows": rows})
# Side B 아래 Ground: 6칸(G1~G6)을 한 줄에. 각 칸은 분할 없는 단일 파렛트.
out.append({
"side": "Ground",
"label": "Ground",
"rows": [{"row": 0, "cols": [[code] for code in RACK_GROUND]}],
})
return out
def aggregate_rack(entries: Iterable[dict[str, Any]]) -> dict[str, int]:
"""랙 입력 항목을 SKU 코드별 총수량으로 집계.
항목: {"sku_code", "units_per_box", "box_count"}.
qty = SUM(units_per_box * box_count). 입수량/박스수 0 또는 코드 공백은 스킵.
반환: { sku_code: total_qty } (낱개·세트 코드 모두 포함 가능)
"""
totals: dict[str, int] = {}
for e in entries:
code = _norm(e.get("sku_code"))
if not code:
continue
# 뚜껑(MD-)은 위치 확인용일 뿐 재고에 반영하지 않으므로 집계 제외.
if is_blocked_code(code):
continue
try:
upb = int(e.get("units_per_box") or 0)
box = int(e.get("box_count") or 0)
except (TypeError, ValueError):
continue
if upb <= 0 or box <= 0:
continue
totals[code] = totals.get(code, 0) + upb * box
return totals
# ════════════════════════════════════════════════════════════
# 코드 분류 / 검증 (순수 함수)
# ════════════════════════════════════════════════════════════
def _norm(code: str) -> str:
return (code or "").strip().upper()
def is_individual_code(code: str) -> bool:
"""낱개 아이템(MT-/MX-/MZ-) 여부."""
return _norm(code).startswith(INDIVIDUAL_PREFIXES)
def is_set_code(code: str) -> bool:
"""세트 아이템(MY-) 여부."""
return _norm(code).startswith(SET_PREFIX)
def is_blocked_code(code: str) -> bool:
"""제외 대상(MD- 뚜껑) 여부."""
return _norm(code).startswith(BLOCKED_PREFIX)
def classify_code(code: str) -> str:
"""코드 종류를 반환. 'individual' | 'set' | 'blocked' | 'unknown'."""
if is_blocked_code(code):
return "blocked"
if is_individual_code(code):
return "individual"
if is_set_code(code):
return "set"
return "unknown"
def validate_movement_code(code: str) -> str:
"""입고/출고/조정 movement 에 쓸 수 있는 코드인지 검증.
낱개(MT-/MX-/MZ-) 허용. MY-/MD-/기타는 ValueError.
반환: 정규화된 코드(대문자/trim).
"""
c = _norm(code)
if not c:
raise ValueError("아이템 코드가 비어 있습니다.")
kind = classify_code(c)
if kind == "blocked":
raise ValueError(f"뚜껑 코드(MD-)는 재고관리 대상이 아닙니다: {c}")
if kind == "set":
raise ValueError(f"세트 코드(MY-)는 입고/출고에 직접 쓸 수 없습니다: {c}")
if kind != "individual":
raise ValueError(f"허용되지 않는 코드입니다(MT-/MX-/MZ- 만 가능): {c}")
return c
def validate_stocktake_code(code: str) -> str:
"""재고조사 라인(daily_stocktake_line)에 쓸 수 있는 코드인지 검증.
낱개(MT-/MX-/MZ-) 또는 세트(MY-) 허용. MD-/기타는 ValueError.
"""
c = _norm(code)
if not c:
raise ValueError("SKU 코드가 비어 있습니다.")
kind = classify_code(c)
if kind == "blocked":
raise ValueError(f"뚜껑 코드(MD-)는 재고조사 대상이 아닙니다: {c}")
if kind not in ("individual", "set"):
raise ValueError(f"허용되지 않는 코드입니다(MT-/MX-/MZ-/MY- 만 가능): {c}")
return c
def validate_rack_code(code: str) -> str:
"""창고 랙 칸에 배치할 수 있는 코드 검증.
낱개(MT-/MX-/MZ-)·세트(MY-) 더해 뚜껑(MD-) 허용한다.
뚜껑은 위치 확인 용도로만 랙에 두며, aggregate_rack 에서 집계 제외되어
재고(daily_stocktake_line)에는 반영되지 않는다.
"""
c = _norm(code)
if not c:
raise ValueError("SKU 코드가 비어 있습니다.")
kind = classify_code(c)
if kind not in ("individual", "set", "blocked"):
raise ValueError(f"허용되지 않는 코드입니다(MT-/MX-/MZ-/MY-/MD- 만 가능): {c}")
return c
def validate_bom_set_code(code: str) -> str:
"""set_bom 의 set_code 검증 — MY- 만 허용."""
c = _norm(code)
if not is_set_code(c):
raise ValueError(f"세트 코드는 MY- 로 시작해야 합니다: {c}")
return c
def validate_bom_component_code(code: str) -> str:
"""set_bom 의 component_code 검증 — MT-/MX-/MZ- 만 허용(MY-/MD- 금지)."""
c = _norm(code)
if is_blocked_code(c):
raise ValueError(f"뚜껑 코드(MD-)는 세트 구성품이 될 수 없습니다: {c}")
if is_set_code(c):
raise ValueError(f"세트(MY-)가 세트의 구성품이 될 수 없습니다(중첩 불가): {c}")
if not is_individual_code(c):
raise ValueError(f"구성품은 MT-/MX-/MZ- 만 가능합니다: {c}")
return c
def validate_qty(qty: Any, *, allow_zero: bool = True, allow_negative: bool = False) -> int:
"""수량 검증/정규화. 정수 변환 + 음수/0 정책 적용."""
try:
n = int(qty)
except (TypeError, ValueError):
raise ValueError(f"수량은 정수여야 합니다: {qty!r}")
if not allow_negative and n < 0:
raise ValueError(f"수량은 음수가 될 수 없습니다: {n}")
if not allow_zero and n == 0:
raise ValueError("수량은 0이 될 수 없습니다.")
return n
# ════════════════════════════════════════════════════════════
# 세트 BOM 분해 / 재고조사 집계 (순수 함수 — DB 무관, 테스트 용이)
# ════════════════════════════════════════════════════════════
def explode_set(
set_code: str, set_qty: int, bom_map: dict[str, list[dict[str, Any]]]
) -> dict[str, int]:
"""세트 1종 × 수량을 BOM 기준으로 낱개 아이템 수량으로 분해.
bom_map: { set_code: [{"component_code": str, "component_qty": int}, ...] }
반환: { component_code: 분해수량 }
BOM 없으면 dict(호출측에서 경고 처리).
"""
out: dict[str, int] = {}
for comp in bom_map.get(_norm(set_code), []):
code = _norm(comp.get("component_code"))
try:
per = int(comp.get("component_qty") or 0)
except (TypeError, ValueError):
per = 0
if not code or per <= 0:
continue
out[code] = out.get(code, 0) + per * int(set_qty)
return out
def explode_stocktake(
lines: Iterable[dict[str, Any]],
bom_map: dict[str, list[dict[str, Any]]],
) -> dict[str, dict[str, Any]]:
"""재고조사 라인을 낱개 아이템 기준 최종 수량으로 집계.
입력 라인: [{"sku_code": str, "qty": int}, ...]
- 낱개 코드(MT/MX/MZ) qty direct_qty 누적
- 세트 코드(MY) qty set_bom 으로 분해해 from_set_qty 누적
반환(낱개 아이템 코드 기준):
{ item_code: {
"direct_qty": 직접 조사 수량 ,
"from_set_qty": 세트 분해 수량 ,
"total_qty": direct_qty + from_set_qty,
} }
추가로 결과에 포함되는 진단 키는 없다(순수 수량만). 세트 BOM 누락
경고는 `missing_bom_sets()` 별도 확인한다.
"""
result: dict[str, dict[str, int]] = {}
def _bucket(code: str) -> dict[str, int]:
return result.setdefault(code, {"direct_qty": 0, "from_set_qty": 0})
for ln in lines:
code = _norm(ln.get("sku_code"))
try:
qty = int(ln.get("qty") or 0)
except (TypeError, ValueError):
qty = 0
if not code or qty < 0:
continue
if is_individual_code(code):
_bucket(code)["direct_qty"] += qty
elif is_set_code(code):
for comp_code, comp_qty in explode_set(code, qty, bom_map).items():
_bucket(comp_code)["from_set_qty"] += comp_qty
# blocked/unknown 은 무시(검증 단계에서 이미 막힘)
out: dict[str, dict[str, Any]] = {}
for code, v in result.items():
total = v["direct_qty"] + v["from_set_qty"]
out[code] = {
"item_code": code,
"direct_qty": v["direct_qty"],
"from_set_qty": v["from_set_qty"],
"total_qty": total,
}
return out
def missing_bom_sets(
lines: Iterable[dict[str, Any]],
bom_map: dict[str, list[dict[str, Any]]],
) -> list[str]:
"""재고조사에 입력된 MY- 세트 중 BOM 구성이 없는 코드 목록.
요구사항 8: 'BOM 이 없는 MY- 코드가 재고조사에 입력되면 경고/오류'.
"""
missing: list[str] = []
seen: set[str] = set()
for ln in lines:
code = _norm(ln.get("sku_code"))
if not is_set_code(code) or code in seen:
continue
seen.add(code)
if not bom_map.get(code):
missing.append(code)
return missing
@@ -0,0 +1,35 @@
{# 한글/영문 토글 버튼 + i18n 스크립트 + 모바일 최적화 CSS. 모든 말레이시아 화면 상단에 include. #}
<style>
/* ── 말레이시아 재고관리 모바일 최적화 ── */
@media (max-width: 820px) {
/* 고정 폭 다단 그리드 → 1단 적층 */
.mys-grid { grid-template-columns: 1fr !important; }
/* 상단 탭/창고선택 바 — 창고 폼 한 줄 풀폭 */
.mys .erp-page-actions form { flex: 1 1 100%; }
.mys .erp-page-actions .erp-select { width: 100%; }
/* 재고조사 상세 정보 바 — 줄바꿈 허용 */
.mys-info-bar { flex-wrap: wrap !important; row-gap: 8px; }
.mys-info-bar .mys-hint { flex: 1 1 100%; white-space: normal !important; }
/* 데이터 테이블 — 카드 적층 대신 가로 스크롤 유지(열 정렬 보존) */
.mys .erp-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.mys .erp-table { min-width: 440px; }
.mys .erp-table thead { display: table-header-group; }
.mys .erp-table tbody tr { display: table-row; padding: 0; }
.mys .erp-table tbody td { display: table-cell; border-bottom: 1px solid var(--color-subtle-ash); padding: 4px 8px; }
/* 랙 칸 — 6열 → 3열 */
.rack-row, .rv-row { grid-template-columns: repeat(3, 1fr) !important; }
}
@media (max-width: 480px) {
/* 랙 칸 — 3열 → 2열, 코드열 폭 자동 */
.rack-row, .rv-row { grid-template-columns: repeat(2, 1fr) !important; }
.mys-compact th.ccol, .mys-compact td.ccol { width: auto !important; }
}
</style>
<div style="display:flex;justify-content:flex-end;margin-bottom:8px;">
<button type="button" id="mys-lang-toggle" class="erp-btn erp-btn-outline">ENG</button>
</div>
<script src="/static/malaysia.js?v=20260622a"></script>
@@ -0,0 +1,18 @@
{# 말레이시아 재고관리 공용 상단 바: 서브탭 + 창고 선택 #}
<div class="erp-page-actions" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<a class="erp-btn {% if active_tab=='status' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/malaysia/?wh={{ selected_wh }}">재고 현황</a>
<a class="erp-btn {% if active_tab=='move' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/malaysia/movements?wh={{ selected_wh }}">입출고</a>
<a class="erp-btn {% if active_tab=='stocktake' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/malaysia/stocktakes?wh={{ selected_wh }}">일일 재고조사</a>
<a class="erp-btn {% if active_tab=='rack' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/malaysia/rack?wh={{ selected_wh }}">창고 랙 보기</a>
<span style="flex:1 1 auto"></span>
<form method="get" style="display:flex;gap:6px;align-items:center;">
<label class="erp-muted" for="wh-sel">창고</label>
<select class="erp-select" id="wh-sel" name="wh" onchange="this.form.submit()">
{% for w in warehouses %}
<option value="{{ w.warehouse_code }}" {% if w.warehouse_code==selected_wh %}selected{% endif %}>{{ w.warehouse_code }} · {{ w.warehouse_name }}</option>
{% endfor %}
</select>
</form>
</div>
@@ -0,0 +1,117 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<style>
.mys-compact .erp-table th,
.mys-compact .erp-table td { padding-top:4px; padding-bottom:4px; }
.mys-compact td.nm { white-space:nowrap; }
.mys-compact th.ccol, .mys-compact td.ccol { white-space:nowrap; width:88px; }
/* Last Stocktake 열 좁게(날짜 고정폭) — 콤보 영역에 공간 양보 */
.mys-compact th.stk, .mys-compact td.stk { white-space:nowrap; width:96px; }
/* 콤보 표 숫자열 compact + 줄바꿈 방지 → 가로 스크롤 제거 */
.mys-combo th.num, .mys-combo td.num { white-space:nowrap; width:52px; text-align:right; }
.mys-combo td.nm { white-space:normal; }
</style>
{% endblock %}
{% block content %}
<section class="mys mys-compact">
{% include "malaysia/_i18n.html" %}
{% set active_tab = 'status' %}
{% include "malaysia/_nav.html" %}
<div class="mys-grid" style="display:grid;grid-template-columns:minmax(0,1fr) 520px;gap:16px;align-items:start;">
<!-- 좌: 낱개로 분리된 전체 재고 -->
<div class="erp-card">
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;">
<h2><span class="i18n">전산 재고 현황 — 낱개 기준</span> ({{ rows|length }})</h2>
<span class="erp-muted">현재고 = 입고 − 출고 + 조정 + 재고조사반영 (콤보는 낱개로 분해 반영됨)</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr>
<th class="ccol">Item Code</th><th>Product Name</th>
<th style="text-align:right">전산재고</th>
<th class="stk">Last Stocktake</th>
<th style="text-align:right">조사수량</th>
<th style="text-align:right">차이</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="ccol">{{ r.item_code }}</td>
<td class="nm" data-en="{{ r.item_name }}" data-ko="{{ ko_names.get(r.item_code, r.item_name) }}">{{ r.item_name }}</td>
<td style="text-align:right;font-weight:600;">{{ "{:,}".format(r.current_qty) }}</td>
<td class="stk">{{ r.last_stocktake_date or '—' }}</td>
<td style="text-align:right">{{ "{:,}".format(r.last_qty) if r.last_qty is not none else '—' }}</td>
<td style="text-align:right;font-weight:600;{% if r.diff %}color:#b91c1c;{% endif %}">
{% if r.diff is not none %}{{ "{:+,}".format(r.diff) }}{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
{% if not rows %}
<tr><td colspan="6" class="erp-muted">표시할 재고가 없습니다.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- 우: 콤보(세트) 단위 재고 -->
<div class="erp-card">
<div class="cpg-card-head"><h2>콤보 재고 (세트 단위)</h2></div>
<span class="erp-muted">
{% if set_stocktake_date %}<span class="i18n">콤보재고 = 재고조사</span>({{ set_stocktake_date }}) <span class="i18n"> 이후 출고</span>{% else %}재고조사 입력 없음{% endif %}
</span>
<div class="erp-table-wrap" style="margin-top:8px;">
<table class="erp-table mys-combo">
<thead><tr>
<th class="ccol">Set Code</th><th>Set Name</th>
<th class="num" data-ko="조사" data-en="Counted">조사</th>
<th class="num" data-ko="출고" data-en="OUT">출고</th>
<th class="num" data-ko="재고" data-en="Stock">재고</th>
</tr></thead>
<tbody>
{% for s in set_rows %}
<tr>
<td class="ccol">{{ s.set_code }}</td>
<td class="nm" data-en="{{ s.set_name }}" data-ko="{{ ko_names.get(s.set_code, s.set_name) }}">{{ s.set_name }}</td>
<td class="num" style="color:#64748b;">{{ "{:,}".format(s.stocktake_qty) }}</td>
<td class="num">{% if s.out_qty %}<span style="color:#b91c1c;">{{ "{:,}".format(s.out_qty) }}</span>{% else %}<span style="color:#94a3b8;"></span>{% endif %}</td>
<td class="num" style="font-weight:600;">{{ "{:,}".format(s.qty) }}</td>
</tr>
{% endfor %}
{% if not set_rows %}
<tr><td colspan="5" class="erp-muted">콤보 재고 입력이 없습니다.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% if is_super %}
<div class="erp-card" style="margin-top:16px;border:1px solid #fecaca;background:#fef2f2;">
<div class="cpg-card-head"><h2 style="color:#b91c1c;">위험 구역 (슈퍼관리자)</h2></div>
<p class="erp-muted" style="margin:4px 0 12px;">
전체 창고의 입출고 이력·재고조사 데이터를 모두 삭제해 재고 수량을 0으로 초기화합니다.
창고/아이템 마스터는 유지됩니다. <strong>되돌릴 수 없습니다.</strong>
</p>
<form method="post" action="/malaysia/reset" onsubmit="return mysConfirmReset()">
<button type="submit" class="erp-btn" style="background:#b91c1c;color:#fff;">재고 초기화</button>
</form>
</div>
<script>
function mysConfirmReset() {
if (!confirm('전체 창고의 입출고 이력과 재고조사 데이터를 모두 삭제합니다.\n재고 수량이 0으로 초기화되며 되돌릴 수 없습니다.\n\n계속하시겠습니까?')) return false;
var t = prompt('정말 초기화하려면 RESET 을 입력하세요.');
if (t !== 'RESET') { alert('초기화가 취소되었습니다.'); return false; }
return true;
}
</script>
{% endif %}
</section>
{% endblock %}
@@ -0,0 +1,174 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<style>
.mys-compact .erp-table th,
.mys-compact .erp-table td { padding-top:4px; padding-bottom:4px; }
.mys-compact .erp-input { padding-top:4px; padding-bottom:4px; }
.mys-compact td.nm { white-space:nowrap; }
.mys-compact th.ccol, .mys-compact td.ccol { white-space:nowrap; width:88px; }
.mys-compact .qcol { width:110px; }
.mys-compact .qcol .erp-input { width:100px; }
.mys-compact .optfields .erp-field { width:100%; }
.mys-compact .optfields .erp-input,
.mys-compact .optfields .erp-select { width:100%; box-sizing:border-box; }
</style>
{% endblock %}
{% block content %}
<section class="mys mys-compact">
{% include "malaysia/_i18n.html" %}
{% set active_tab = 'move' %}
{% include "malaysia/_nav.html" %}
<!-- 3분할: 좌(옵션) · 중(낱개) · 우(세트), 버튼 하나 -->
<form method="post" action="/malaysia/movements/apply" onsubmit="return mysCheckComboIn(this)">
<input type="hidden" name="warehouse_code" value="{{ selected_wh }}" />
<div class="mys-grid" style="display:grid;grid-template-columns:280px 1fr 1fr;gap:16px;align-items:start;">
<!-- 좌: 종류 / 날짜 / 메모 -->
<div class="erp-card">
<div class="cpg-card-head"><h2>등록 옵션</h2></div>
<div class="optfields" style="display:flex;flex-direction:column;gap:10px;margin-top:8px;">
<label class="erp-field"><span>이동 종류 (낱개)</span>
<select class="erp-select" name="movement_type" required>
<option value="IN">IN (입고)</option>
<option value="OUT">OUT (출고)</option>
<option value="ADJUST">ADJUST (조정 ±)</option>
</select></label>
<label class="erp-field"><span>날짜</span>
<input class="erp-input" type="date" name="movement_date" value="{{ today }}" required /></label>
<label class="erp-field"><span>메모</span>
<input class="erp-input" type="text" name="memo" /></label>
</div>
<div class="erp-page-actions" style="margin-top:12px;display:flex;gap:8px;">
<button type="submit" class="erp-btn erp-btn-primary">입력</button>
<button type="button" class="erp-btn erp-btn-outline" onclick="mysDownloadMovements(this)">다운로드</button>
</div>
</div>
<!-- 중: 낱개 -->
<div class="erp-card">
<div class="cpg-card-head"><h2>낱개 상품</h2></div>
<div class="erp-table-wrap" style="margin-top:8px;">
<table class="erp-table">
<thead><tr><th class="ccol">Code</th><th>Name</th><th class="qcol">Qty</th></tr></thead>
<tbody>
{% for it in individual_items %}
<tr>
<td class="ccol">{{ it.item_code }}</td>
<td class="nm" data-en="{{ it.item_name }}" data-ko="{{ ko_names.get(it.item_code, it.item_name) }}">{{ it.item_name }}</td>
<td class="qcol"><input class="erp-input" type="number" name="qty_{{ it.item_code }}" placeholder="—" /></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- 우: 세트 -->
<div class="erp-card">
<div class="cpg-card-head"><h2>콤보 상품</h2></div>
<div class="erp-table-wrap" style="margin-top:8px;">
<table class="erp-table">
<thead><tr><th class="ccol">Set Code</th><th>Set Name</th><th class="qcol">Qty</th></tr></thead>
<tbody>
{% for it in set_items %}
<tr>
<td class="ccol">{{ it.item_code }}</td>
<td class="nm" data-en="{{ it.item_name }}" data-ko="{{ ko_names.get(it.item_code, it.item_name) }}">{{ it.item_name }}</td>
<td class="qcol"><input class="erp-input" type="number" min="0" name="qty_{{ it.item_code }}" placeholder="—" /></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</form>
<script>
// 콤보(MY-)는 입고 불가 — IN 선택 + 콤보 수량 입력 시 안내 후 제출 차단
function mysCheckComboIn(form) {
var type = form.querySelector('[name=movement_type]').value;
if (type !== 'IN') return true;
var keyed = [];
form.querySelectorAll('input[name^="qty_MY-"]').forEach(function (inp) {
var v = (inp.value || '').trim();
if (v !== '' && v !== '0') keyed.push(inp.name.replace('qty_', ''));
});
if (keyed.length) {
var en = localStorage.getItem('mys_lang') === 'en';
alert(en
? 'Combo products cannot be received.\nReceive them as separate single items.\n\nNot allowed: ' + keyed.join(', ')
: '콤보 상품은 입고할 수 없습니다.\n낱개로 분리해서 입고하세요.\n\n입고 불가 콤보: ' + keyed.join(', '));
return false;
}
return true;
}
function mysDownloadMovements(btn) {
var form = btn.closest('form');
var type = form.querySelector('[name=movement_type]').value;
var date = form.querySelector('[name=movement_date]').value;
var wh = form.querySelector('[name=warehouse_code]').value;
if (type !== 'IN' && type !== 'OUT') {
alert('다운로드는 IN(입고) 또는 OUT(출고)만 가능합니다.');
return;
}
if (!date) { alert('날짜를 선택하세요.'); return; }
var url = '/malaysia/movements/export?wh=' + encodeURIComponent(wh)
+ '&type=' + encodeURIComponent(type)
+ '&date=' + encodeURIComponent(date);
window.location.href = url;
}
</script>
<!-- 이동 이력 -->
<div class="erp-card" style="margin-top:16px;">
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<h2><span class="i18n">이동 이력</span>{% if filter_date %} · {{ filter_date }}{% endif %}</h2>
<span style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<form method="get" style="display:flex;gap:6px;align-items:center;">
<input type="hidden" name="wh" value="{{ selected_wh }}" />
{% if filter_type %}<input type="hidden" name="type" value="{{ filter_type }}" />{% endif %}
<input class="erp-input" type="date" name="date" value="{{ filter_date }}" onchange="this.form.submit()" />
{% if filter_date %}<a class="erp-btn erp-btn-outline" href="/malaysia/movements?wh={{ selected_wh }}{% if filter_type %}&type={{ filter_type }}{% endif %}">날짜해제</a>{% endif %}
</form>
<a class="erp-btn erp-btn-outline" href="/malaysia/movements?wh={{ selected_wh }}{% if filter_date %}&date={{ filter_date }}{% endif %}">전체</a>
{% for t in ['IN','OUT','ADJUST','STOCKTAKE'] %}
<a class="erp-btn {% if filter_type==t %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/malaysia/movements?wh={{ selected_wh }}&type={{ t }}{% if filter_date %}&date={{ filter_date }}{% endif %}">{{ t }}</a>
{% endfor %}
</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead><tr><th>Date</th><th class="i18n">구분</th><th>Type</th><th>Item</th><th style="text-align:right">Qty</th><th>Memo</th><th>By</th></tr></thead>
<tbody>
{% for m in movements %}
<tr>
<td>{{ m.date_label }}</td>
<td>
{% if m.kind == 'set' %}
<span class="i18n erp-badge" style="background:#fef3c7;color:#92400e;">콤보</span>
{% else %}
<span class="i18n erp-badge" style="background:#e0e7ff;color:#3730a3;">낱개</span>
{% endif %}
</td>
<td>{{ m.movement_type }}</td>
<td>{{ m.item_code }}</td>
<td style="text-align:right">{{ m.qty }}</td>
<td>{{ m.memo or '—' }}</td>
<td>{{ m.created_by or '—' }}</td>
</tr>
{% endfor %}
{% if not movements %}
<tr><td colspan="7" class="erp-muted">이동 이력이 없습니다.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,291 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<style>
.rv-board { display:flex; flex-direction:column; gap:18px; }
.rv-side { width:100%; }
.rv-side h3 { text-align:center; margin:0 0 8px; }
.rv-row { display:grid; grid-template-columns:repeat(6, 1fr); gap:6px; margin-bottom:6px; }
.rv-cell {
border:1px solid #cbd5e1; border-radius:6px; padding:7px;
background:#f8fafc; min-height:104px; display:flex; flex-direction:column; min-width:0;
cursor:grab; transition:box-shadow .12s, border-color .12s, background .12s;
}
.rv-cell.is-empty { background:#fff; }
.rv-cell.is-selected { border-color:#2563eb; box-shadow:0 0 0 2px #93c5fd inset; background:#eff6ff; }
.rv-cell.is-dragover { border-color:#2563eb; background:#dbeafe; }
.rv-cell.dragging { opacity:.45; }
.rv-cell-head {
display:flex; align-items:center; justify-content:space-between;
font-size:14px; font-weight:700; color:#475569; margin-bottom:5px;
border-bottom:1px solid #e2e8f0; padding-bottom:4px;
}
.rv-cell-head .rv-pick {
flex:0 0 auto; width:16px; height:16px; margin:0; cursor:pointer;
}
.rv-items { display:flex; flex-direction:column; gap:4px; }
.rv-item { font-size:14px; line-height:1.3; }
.rv-item .rv-name { font-weight:700; color:#0f172a; }
.rv-item .rv-calc { color:#475569; }
.rv-item .rv-qty { color:#2563eb; font-weight:700; }
.rv-empty-note { font-size:13px; color:#cbd5e1; }
/* 수정됨 표시: 저장 버튼을 빨강으로 강조 */
#rv-save-btn.is-dirty {
background:#dc2626 !important; border-color:#dc2626 !important; color:#fff !important;
}
#rv-save-btn.is-dirty:hover { background:#b91c1c !important; border-color:#b91c1c !important; }
/* ── 인쇄 미리보기 모달 ── */
.rvp-overlay {
display:none; position:fixed; inset:0; z-index:2000;
background:rgba(15,23,42,.55); overflow:auto; padding:24px;
}
.rvp-overlay.is-open { display:block; }
.rvp-toolbar {
position:sticky; top:0; display:flex; gap:8px; justify-content:flex-end;
align-items:center; margin-bottom:16px;
}
.rvp-toolbar .rvp-title { margin-right:auto; color:#fff; font-weight:700; font-size:16px; }
.rvp-pages { display:flex; flex-direction:column; gap:18px; align-items:center; }
/* 화면 미리보기: A4 가로 비율(297:210) 시트 — 여백 최소, 글자 최대 */
.rvp-sheet {
background:#fff; width:min(100%, 1000px); aspect-ratio:297/210;
box-shadow:0 8px 24px rgba(0,0,0,.3); border-radius:4px;
display:flex; flex-direction:column; padding:1.5%; box-sizing:border-box;
}
.rvp-list { flex:1 1 auto; display:flex; flex-direction:column; justify-content:center; gap:4%; }
.rvp-line { display:flex; flex-direction:column; align-items:center; text-align:center; gap:0.5%; }
.rvp-line .rvp-name { font-weight:900; font-size:7vmin; color:#0f172a; line-height:1.05; }
.rvp-line .rvp-size { font-weight:900; font-size:14vmin; color:#0f172a; line-height:1; }
.rvp-line .rvp-calc { font-weight:800; font-size:6vmin; color:#1e293b; margin-top:1.5%; }
.rvp-line .rvp-calc .rvp-box { font-size:9.5vmin; font-weight:900; }
.rvp-empty { text-align:center; font-weight:900; font-size:9vmin; color:#94a3b8; margin:auto 0; }
/* ── 실제 인쇄 ── */
@media print {
/* 가로 인쇄: 상하 여백(=프린터 좌우)을 0으로, 좌우만 4mm 유지해 세로 공간 최대 확보 */
@page { size: A4 landscape; margin: 0 4mm; }
/* 비인쇄 요소는 display:none 으로 공간까지 제거 — visibility:hidden 은
공간이 남아 overlay(static)가 아래로 밀려 1페이지가 비던 문제 방지. */
.erp-sidebar, .erp-topbar { display:none !important; }
/* 앱 셸이 height:100vh + overflow:hidden 으로 한 화면만 보이게 잘라서,
인쇄 시 둘째 칸부터 잘려 1장만 나오던 문제. 인쇄에선 조상들의
높이·오버플로 제한을 풀어 모든 칸(sheet)이 흐르게 한다. */
html, body.erp-app-body, .erp-app, .erp-content, .erp-page, .mys {
height:auto !important; min-height:0 !important; max-height:none !important;
overflow:visible !important;
}
.erp-content { margin:0 !important; }
.erp-page { padding:0 !important; }
body.printing-cells .mys > *:not(.rvp-overlay) { display:none !important; }
body.printing-cells .rvp-overlay {
display:block !important; position:static !important; padding:0 !important;
background:#fff !important; overflow:visible !important;
}
.rvp-toolbar { display:none !important; }
/* flex 컨테이너면 Chrome이 자식의 page-break를 무시해 여러 칸이 한 장에
뭉쳐 잘림. block 으로 바꿔 칸(파렛트)마다 한 장씩 끊어 인쇄. */
.rvp-pages { display:block !important; gap:0 !important; }
.rvp-sheet {
width:100% !important; height:100vh !important; aspect-ratio:auto !important;
box-shadow:none !important; border-radius:0 !important;
padding:2mm !important;
/* break-before 가 Chrome에서 더 안정적: 첫 칸 제외 매 칸을 새 장으로. */
break-before:page; page-break-before:always;
}
.rvp-sheet:first-child { break-before:auto; page-break-before:auto; }
/* 인쇄 시 글씨 더 크게(멀리서도 보이게) — cm 기준.
칸에 상품이 1개일 때 기준. 2개·3개 이상이면 한 장에 다 들어가도록 단계 축소. */
.rvp-line .rvp-name { font-size:2.6cm; }
.rvp-line .rvp-size { font-size:5.5cm; }
.rvp-line .rvp-calc { font-size:1.8cm; }
.rvp-line .rvp-calc .rvp-box { font-size:3cm; }
.rvp-empty { font-size:3cm; }
/* 상품 2개: 절반 가량으로 축소(2개 합쳐도 A4 가로 1장에 맞게) */
.rvp-sheet.rvp-multi .rvp-line .rvp-name { font-size:1.5cm; }
.rvp-sheet.rvp-multi .rvp-line .rvp-size { font-size:3.1cm; }
.rvp-sheet.rvp-multi .rvp-line .rvp-calc { font-size:1.05cm; }
.rvp-sheet.rvp-multi .rvp-line .rvp-calc .rvp-box { font-size:1.7cm; }
/* 상품 3개 이상: 더 작게 */
.rvp-sheet.rvp-many .rvp-line .rvp-name { font-size:1cm; }
.rvp-sheet.rvp-many .rvp-line .rvp-size { font-size:2cm; }
.rvp-sheet.rvp-many .rvp-line .rvp-calc { font-size:0.75cm; }
.rvp-sheet.rvp-many .rvp-line .rvp-calc .rvp-box { font-size:1.2cm; }
}
/* ── 창고 POG (전체 도면 A4 가로 1장) ── */
.pog-overlay {
display:none; position:fixed; inset:0; z-index:2000;
background:rgba(15,23,42,.55); overflow:auto; padding:24px;
}
.pog-overlay.is-open { display:block; }
.pog-toolbar {
position:sticky; top:0; display:flex; gap:8px; justify-content:flex-end;
align-items:center; margin-bottom:16px;
}
.pog-toolbar .pog-title { margin-right:auto; color:#fff; font-weight:700; font-size:16px; }
.pog-pages { display:flex; flex-direction:column; gap:18px; align-items:center; }
/* A4 가로(297:210) 시트 — 면(Side A/B/Ground)마다 1장.
container-type:size → 자식이 cqh/cqw 로 시트 크기에 맞춰 확대/축소 */
.pog-sheet {
background:#fff; width:min(100%, 1180px); aspect-ratio:297/210;
box-shadow:0 8px 24px rgba(0,0,0,.3); border-radius:4px;
padding:1.8cqh 1.4cqw; box-sizing:border-box;
display:flex; flex-direction:column; overflow:hidden;
container-type:size;
}
.pog-sheet .pog-sheet-head {
display:flex; justify-content:space-between; align-items:baseline;
font-weight:800; color:#0f172a; margin-bottom:1.2cqh; flex:0 0 auto;
}
.pog-sheet .pog-sheet-head .pog-t { font-size:2.8cqh; }
.pog-sheet .pog-sheet-head .pog-meta { font-size:1.7cqh; color:#475569; font-weight:600; }
/* 면 1개가 시트 높이를 꽉 채운다(행 flex 균등 분배 → 칸 커짐) */
.pog-sheet .rv-side { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; }
.pog-sheet .rv-side h3 { display:none; }
.pog-sheet .rv-row { flex:1 1 0; gap:1cqw; margin:0 0 1cqh; min-height:0; }
.pog-sheet .rv-row:last-child { margin-bottom:0; }
.pog-sheet .rv-cell {
height:auto; min-height:0; padding:0.7cqh 0.8cqw; border-radius:4px;
cursor:default; overflow:hidden; background:#f8fafc; justify-content:flex-start;
}
.pog-sheet .rv-cell.is-empty { background:#fff; }
.pog-sheet .rv-cell-head {
font-size:1.55cqh; font-weight:800; margin-bottom:0.5cqh; padding-bottom:0.35cqh;
}
.pog-sheet .rv-pick { display:none !important; }
.pog-sheet .rv-items { gap:0.4cqh; }
.pog-sheet .rv-item { font-size:1.55cqh; line-height:1.25; }
.pog-sheet .rv-empty-note { font-size:1.55cqh; }
@media print {
body.printing-pog .erp-sidebar, body.printing-pog .erp-topbar { display:none !important; }
body.printing-pog .mys > *:not(.pog-overlay) { display:none !important; }
body.printing-pog .pog-overlay {
display:block !important; position:static !important; padding:0 !important;
background:#fff !important; overflow:visible !important;
}
body.printing-pog .pog-toolbar { display:none !important; }
body.printing-pog .pog-pages { display:block !important; }
/* 면(시트)마다 한 장(A4 가로). container-type:size 가 인쇄 시 자식 grid 를
깨는 Chrome 버그 회피 → containment 해제하고 글자/여백은 mm 로 고정.
(화면 미리보기는 위쪽 cqh 그대로 사용) */
body.printing-pog .pog-sheet {
container-type:normal !important;
width:100% !important; height:auto !important; aspect-ratio:auto !important;
padding:4mm 4mm !important;
box-shadow:none !important; border-radius:0 !important;
break-before:page; page-break-before:always; page-break-inside:avoid;
}
body.printing-pog .pog-sheet:first-child { break-before:auto; page-break-before:auto; }
/* 면 자연 높이로 흐르게(flex 해제) → 프린터 여백 변동에도 한 면=한 장 */
body.printing-pog .pog-sheet .rv-side { display:block !important; flex:none !important; }
/* 6열 강제(인쇄에서 3열로 덮이는 문제 차단) */
body.printing-pog .pog-sheet .rv-row {
display:grid !important; grid-template-columns:repeat(6, 1fr) !important;
gap:2mm !important; margin:0 0 2mm !important;
}
body.printing-pog .pog-sheet .pog-sheet-head { margin-bottom:4mm !important; }
body.printing-pog .pog-sheet .pog-sheet-head .pog-t { font-size:8mm !important; }
body.printing-pog .pog-sheet .pog-sheet-head .pog-meta { font-size:4.5mm !important; }
body.printing-pog .pog-sheet .rv-cell {
min-height:50mm !important; height:auto !important; padding:1.5mm 2mm !important;
}
body.printing-pog .pog-sheet .rv-cell-head {
font-size:3.6mm !important; margin-bottom:1mm !important; padding-bottom:0.8mm !important;
}
body.printing-pog .pog-sheet .rv-items { gap:1mm !important; }
body.printing-pog .pog-sheet .rv-item { font-size:3.6mm !important; }
body.printing-pog .pog-sheet .rv-empty-note { font-size:3.6mm !important; }
}
</style>
{% endblock %}
{% block content %}
<section class="mys mys-compact">
{% include "malaysia/_i18n.html" %}
{% set active_tab = 'rack' %}
{% include "malaysia/_nav.html" %}
<div class="erp-card">
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<h2>창고 랙 보기</h2>
<span class="erp-muted">
{% if stocktake %}
<span class="i18n">기준 재고조사</span>: #{{ stocktake.id }} · {{ stocktake.stocktake_date }}
{% if stocktake.status=='finalized' %}<span class="erp-badge erp-badge-success">확정</span>
{% elif stocktake.status=='cancelled' %}<span class="erp-badge erp-badge-neutral">취소</span>
{% else %}<span class="erp-badge erp-badge-inverse">작성중</span>{% endif %}
{% else %}
<span class="i18n">표시할 재고조사가 없습니다.</span>
{% endif %}
</span>
</div>
{% if stocktake %}
<div class="erp-page-actions" style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<span class="erp-muted i18n" style="flex:1 1 auto;min-width:0;">칸을 드래그해 다른 칸으로 옮기면 구성이 이동(채워진 칸끼리는 맞교환)됩니다. 칸 번호는 그대로입니다. 인쇄할 칸은 체크 후 인쇄하세요.</span>
<a href="/malaysia/rack/export?wh={{ selected_wh }}" class="erp-btn erp-btn-outline">다운로드</a>
<button type="button" id="rv-print-btn" class="erp-btn erp-btn-outline">인쇄 미리보기</button>
<button type="button" id="rv-pog-btn" class="erp-btn erp-btn-outline">POG 출력</button>
<button type="button" id="rv-save-btn" class="erp-btn erp-btn-primary">저장</button>
</div>
{% endif %}
<form id="rv-save-form" method="post" action="/malaysia/rack/save" style="display:none;">
<input type="hidden" name="warehouse_code" value="{{ selected_wh }}" />
<div id="rv-save-fields"></div>
</form>
<div class="rv-board" id="rv-board" style="margin-top:10px;"
data-wh="{{ selected_wh }}"
data-stocktake="{% if stocktake %}#{{ stocktake.id }} · {{ stocktake.stocktake_date }}{% endif %}">
{% for side in rack_layout %}
<div class="rv-side">
<h3>{{ side.label }}</h3>
{% for row in side.rows %}
<div class="rv-row">
{% for col in row.cols %}
{% for cell in col %}
{% set entries = rack_by_cell.get(cell, []) %}
<div class="rv-cell {% if not entries %}is-empty{% endif %}"
data-cell="{{ cell }}" draggable="true"
data-entries='[{% for e in entries %}{"sku":"{{ e.sku_code }}","ko":{{ (ko_names.get(e.sku_code, e.item_name)) | tojson }},"en":{{ e.item_name | tojson }},"upb":{{ e.units_per_box }},"box":{{ e.box_count }}}{% if not loop.last %},{% endif %}{% endfor %}]'>
<div class="rv-cell-head">
<span class="rv-code">{{ cell }}</span>
<input type="checkbox" class="rv-pick" title="인쇄 선택" draggable="false" />
</div>
<div class="rv-items"><!-- JS 렌더 --></div>
</div>
{% endfor %}
{% endfor %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
<!-- 인쇄 미리보기 모달 -->
<div class="rvp-overlay" id="rvp-overlay">
<div class="rvp-toolbar">
<span class="rvp-title i18n">인쇄 미리보기</span>
<button type="button" id="rvp-print" class="erp-btn erp-btn-primary">인쇄</button>
<button type="button" id="rvp-close" class="erp-btn erp-btn-outline">닫기</button>
</div>
<div class="rvp-pages" id="rvp-pages"></div>
</div>
<!-- 창고 POG 미리보기 모달 (전체 도면 1장) -->
<div class="pog-overlay" id="pog-overlay">
<div class="pog-toolbar">
<span class="pog-title i18n">창고 POG 미리보기</span>
<button type="button" id="pog-print" class="erp-btn erp-btn-primary">인쇄</button>
<button type="button" id="pog-close" class="erp-btn erp-btn-outline">닫기</button>
</div>
<div class="pog-pages" id="pog-pages"></div>
</div>
</section>
<script src="/static/malaysia_rack_view.js?v=20260619c"></script>
{% endblock %}
@@ -0,0 +1,203 @@
{% extends "erp_base.html" %}
{% macro rack_row(cell, items, entry=None, disabled=False) %}
<div class="rk-row">
<input type="hidden" name="rk_cell" value="{{ cell }}">
<select name="rk_sku" class="erp-input rk-sku" {% if disabled %}disabled{% endif %}>
<option value="">— 선택 —</option>
{% for it in items %}
<option value="{{ it.item_code }}" {% if entry and entry.sku_code == it.item_code %}selected{% endif %}>{{ it.item_name }}</option>
{% endfor %}
</select>
<div class="rk-line">
<label class="rk-f"><span>입수량</span>
<input class="erp-input rk-upb" type="number" min="1" name="rk_upb"
value="{{ entry.units_per_box if entry else '' }}" {% if disabled %}disabled{% endif %} /></label>
<label class="rk-f"><span>박스</span>
<input class="erp-input rk-box" type="number" min="1" name="rk_box"
value="{{ entry.box_count if entry else '' }}" {% if disabled %}disabled{% endif %} /></label>
</div>
<div class="rk-foot">
<span><span class="i18n">합계</span> <b class="rk-sub">{{ "{:,}".format(entry.units_per_box * entry.box_count) if entry else 0 }}</b></span>
{% if not disabled %}<button type="button" class="rk-del" title="행 삭제">× <span class="i18n">삭제</span></button>{% endif %}
</div>
</div>
{% endmacro %}
{% block head_extra %}
<style>
.mys-compact .erp-table th,
.mys-compact .erp-table td { padding-top:4px; padding-bottom:4px; }
/* 상단 정보 바 — 한 줄 */
.mys-info-bar {
display:flex; align-items:center; gap:16px; flex-wrap:nowrap;
border:1px solid #e2e8f0; border-radius:10px; background:#fff; padding:10px 14px;
}
.mys-info-bar h2 { margin:0; white-space:nowrap; }
.mys-info-bar > div { white-space:nowrap; }
.mys-info-bar .mys-hint {
flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis;
white-space:nowrap; font-size:12px;
}
.mys-info-bar > button { flex:0 0 auto; }
/* 랙 보드 — 두 면을 세로로 적층(각 면 풀폭 → 칸 넓게) */
.rack-board { display:flex; flex-direction:column; gap:18px; }
.rack-side { width:100%; }
.rack-side h3 { text-align:center; margin:0 0 8px; }
.rack-row { display:grid; grid-template-columns:repeat(6, 1fr); gap:6px; margin-bottom:6px; }
.rack-cell {
border:1px solid #cbd5e1; border-radius:6px; padding:4px;
background:#f8fafc; min-height:70px; display:flex; flex-direction:column; min-width:0;
}
.rack-cell-head {
display:flex; align-items:center; gap:4px; justify-content:space-between;
font-size:11px; font-weight:600; color:#475569; margin-bottom:4px;
}
.rack-cell-head .rk-add {
border:1px solid #94a3b8; background:#fff; border-radius:4px;
width:20px; height:20px; line-height:1; padding:0; cursor:pointer; font-weight:700; flex:0 0 auto;
}
.rk-rows { display:flex; flex-direction:column; gap:6px; }
/* 입력란 세로 배치 — 좁은 칸에서도 레이블이 보이게 */
.rk-row {
display:flex; flex-direction:column; gap:3px;
border:1px solid #e2e8f0; border-radius:5px; background:#fff; padding:4px;
}
.rk-row .rk-sku { font-size:12px; padding:2px 4px; width:100%; min-width:0; }
.rk-row .rk-line { display:flex; gap:6px; }
.rk-row .rk-f { display:flex; align-items:center; gap:3px; font-size:11px; margin:0; flex:1 1 0; min-width:0; }
.rk-row .rk-f > span { flex:0 0 auto; color:#64748b; }
.rk-row .rk-upb, .rk-row .rk-box {
flex:1 1 auto; min-width:0; font-size:12px; padding:2px 4px; text-align:right;
}
.rk-row .rk-foot {
display:flex; align-items:center; justify-content:space-between;
font-size:11px; color:#475569; margin-top:1px;
}
.rk-row .rk-sub { color:#2563eb; font-weight:600; }
.rk-row .rk-del {
border:none; background:transparent; color:#dc2626; cursor:pointer;
font-size:11px; line-height:1; padding:0;
}
</style>
{% endblock %}
{% block content %}
<section class="mys mys-compact">
{% include "malaysia/_i18n.html" %}
<div class="erp-page-actions">
<a class="erp-btn erp-btn-primary" href="/malaysia/stocktakes?wh={{ stocktake.warehouse_code }}">◀◀ 조사 목록</a>
</div>
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/rack/bulk">
<!-- 상단 정보 스트립 (한 줄) -->
<div class="mys-info-bar">
<h2><span class="i18n">재고조사</span> #{{ stocktake.id }}</h2>
<div><span class="erp-muted">날짜</span> {{ stocktake.stocktake_date }}</div>
<div><span class="erp-muted">창고</span> {{ stocktake.warehouse_code }}</div>
<div><span class="erp-muted">상태</span>
{% if stocktake.status=='finalized' %}<span class="erp-badge erp-badge-success">확정</span>
{% elif stocktake.status=='cancelled' %}<span class="erp-badge erp-badge-neutral">취소</span>
{% else %}<span class="erp-badge erp-badge-inverse">작성중</span>{% endif %}
</div>
{% if stocktake.finalized_at %}<div><span class="erp-muted">확정시각</span> {{ stocktake.finalized_at }}</div>{% endif %}
{% if missing_bom %}<span style="color:#b91c1c;font-weight:600;white-space:nowrap;">⚠ BOM 누락: {{ missing_bom|join(', ') }}</span>{% endif %}
<span class="erp-muted i18n mys-hint">랙 칸에 아이템·입수량(박스당 개수)·박스 수를 입력하면 낱개/콤보 수량이 자동 집계됩니다. 한 칸에 여러 아이템은 + 로 추가하세요. 저장 또는 확정 시 화면 입력이 함께 반영됩니다.</span>
{% if is_draft and has_prev_rack %}
<button type="submit" class="erp-btn erp-btn-outline"
formaction="/malaysia/stocktakes/{{ stocktake.id }}/rack/load-previous"
formnovalidate
onclick="return confirm('이전 재고조사의 저장값을 불러옵니다. 현재 화면에 입력한 내용은 사라지고 이전값으로 대체됩니다. 계속?');">이전값 불러오기</button>
{% endif %}
{% if is_draft %}<button type="submit" class="erp-btn erp-btn-outline">입력 저장</button>{% endif %}
{% if is_draft and is_admin %}
<button type="submit" class="erp-btn erp-btn-primary"
formaction="/malaysia/stocktakes/{{ stocktake.id }}/rack/finalize"
onclick="return confirm('현재 입력한 랙 내용을 저장하고 확정합니다. 확정하면 전산 재고와의 차이가 STOCKTAKE 이동으로 기록되고 이후 수정 불가합니다. 계속?');">확정 (전산 반영)</button>
{% endif %}
</div>
<!-- 랙 보드 -->
<div class="erp-card" style="margin-top:12px;">
<div class="cpg-card-head"><h2>랙 입력 (박스 단위)</h2></div>
<div class="rack-board" style="margin-top:10px;">
{% for side in rack_layout %}
<div class="rack-side">
<h3>{{ side.label }}</h3>
{% for row in side.rows %}
<div class="rack-row">
{% for col in row.cols %}
{% for cell in col %}
<div class="rack-cell" data-cell="{{ cell }}">
<div class="rack-cell-head">
<span>{{ cell }}</span>
{% if is_draft %}<button type="button" class="rk-add" title="아이템 추가">+</button>{% endif %}
</div>
<div class="rk-rows">
{% set entries = rack_by_cell.get(cell, []) %}
{% for e in entries %}
{{ rack_row(cell, rack_items, entry=e, disabled=(not is_draft)) }}
{% endfor %}
{% if is_draft and not entries %}
{{ rack_row(cell, rack_items) }}
{% endif %}
</div>
</div>
{% endfor %}
{% endfor %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
</form>
<!-- JS 클론용 빈 행 템플릿(옵션 1벌만 보유) -->
<template id="rk-row-tpl">
{{ rack_row('', rack_items) }}
</template>
<!-- 확정/취소/삭제 -->
<div class="erp-page-actions" style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;">
{% if is_draft %}
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/cancel" onsubmit="return confirm('이 조사를 취소합니다. 계속?');">
<button type="submit" class="erp-btn erp-btn-danger">취소</button>
</form>
{% endif %}
{% if is_super %}
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/delete"
onsubmit="return confirm('이 재고조사를 완전 삭제합니다(되돌릴 수 없음). 계속?');">
<button type="submit" class="erp-btn erp-btn-danger">삭제 (슈퍼관리자)</button>
</form>
{% endif %}
</div>
<!-- 최종 계산 결과 (낱개 기준) -->
<div class="erp-card" style="margin-top:16px;">
<div class="cpg-card-head"><h2>최종 계산 (낱개 기준)</h2></div>
<span class="erp-muted">total_qty = direct_qty(낱개 직접) + from_set_qty(콤보 분해)</span>
<div class="erp-table-wrap">
<table class="erp-table">
<thead><tr><th>Item Code</th><th>Product Name</th><th style="text-align:right">Direct</th><th style="text-align:right">From Set</th><th style="text-align:right">Total</th></tr></thead>
<tbody>
{% for r in result_rows %}
<tr>
<td>{{ r.item_code }}</td>
<td>{{ r.item_name }}</td>
<td style="text-align:right">{{ "{:,}".format(r.direct_qty) }}</td>
<td style="text-align:right">{{ "{:,}".format(r.from_set_qty) }}</td>
<td style="text-align:right;font-weight:600;">{{ "{:,}".format(r.total_qty) }}</td>
</tr>
{% endfor %}
{% if not result_rows %}
<tr><td colspan="5" class="erp-muted">입력된 수량이 없습니다.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</section>
<script src="/static/malaysia_rack.js?v=20260614c"></script>
{% endblock %}
@@ -0,0 +1,80 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="mys mys-compact">
{% include "malaysia/_i18n.html" %}
{% set active_tab = 'stocktake' %}
{% include "malaysia/_nav.html" %}
<div class="mys-grid" style="display:grid;grid-template-columns:340px 1fr;gap:16px;align-items:start;">
<!-- 신규 조사 생성 -->
<div class="erp-card">
<div class="cpg-card-head"><h2>재고조사 생성</h2></div>
<form method="post" action="/malaysia/stocktakes" onsubmit="return mysCheckFinalizedDate(this)" style="margin-top:10px;display:flex;flex-direction:column;gap:8px;">
<input type="hidden" name="warehouse_code" value="{{ selected_wh }}" />
<label class="erp-field"><span>조사 날짜</span>
<input class="erp-input" type="date" name="stocktake_date" value="{{ today }}" required /></label>
<label class="erp-field"><span>메모</span>
<input class="erp-input" type="text" name="memo" /></label>
<div class="erp-page-actions"><button type="submit" class="erp-btn erp-btn-primary">생성</button></div>
</form>
<p class="erp-muted">생성 후 상세에서 낱개/세트 수량을 입력하고 확정하세요.</p>
</div>
<!-- 조사 목록 -->
<div class="erp-card">
<div class="cpg-card-head"><h2><span class="i18n">조사 목록</span> ({{ stocktakes|length }})</h2></div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead><tr><th>#</th><th>Date</th><th>Warehouse</th><th>Status</th><th>By</th><th></th></tr></thead>
<tbody>
{% for s in stocktakes %}
<tr>
<td>{{ s.id }}</td>
<td>{{ s.stocktake_date }}</td>
<td>{{ s.warehouse_code }}</td>
<td>
{% if s.status=='finalized' %}<span class="erp-badge erp-badge-success">확정</span>
{% elif s.status=='cancelled' %}<span class="erp-badge erp-badge-neutral">취소</span>
{% else %}<span class="erp-badge erp-badge-inverse">작성중</span>{% endif %}
</td>
<td>{{ s.created_by or '—' }}</td>
<td style="display:flex;gap:6px;">
<a class="erp-btn erp-btn-outline" href="/malaysia/stocktakes/{{ s.id }}">열기</a>
{% if is_super %}
<form method="post" action="/malaysia/stocktakes/{{ s.id }}/delete" onsubmit="return confirm('재고조사 #{{ s.id }} 완전 삭제. 계속?');">
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
{% if not stocktakes %}
<tr><td colspan="6" class="erp-muted">재고조사가 없습니다.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<script>
// 이미 확정된 날짜 목록(현재 창고). 그 날짜로 생성 시도 시 안내 후 차단.
var MYS_FINALIZED_DATES = [
{% for s in stocktakes %}{% if s.status == 'finalized' %}"{{ s.stocktake_date }}",{% endif %}{% endfor %}
];
function mysCheckFinalizedDate(form) {
var d = (form.querySelector('[name=stocktake_date]').value || '').trim();
if (MYS_FINALIZED_DATES.indexOf(d) !== -1) {
var en = localStorage.getItem('mys_lang') === 'en';
alert(en
? d + ' is already finalized; you cannot create another stocktake for this date.'
: d + ' 은 재고조사가 확정되어 다시 재고조사를 생성할 수 없습니다.');
return false;
}
return true;
}
</script>
</section>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
"""프로젝트 관리(아사나식) 모듈.
라우터/저장소/템플릿을 디렉토리에서 관리한다(malaysia/dispatch 패턴).
- 라우터: `router.py` (FastAPI APIRouter, prefix=/project)
- 저장소: `db.py` (project_db / PostgreSQL 전용) + `store.py` (상수/순수 검증)
- 템플릿: `templates/project/`
- 메일: 전역 `app.mail.send_email` (업무 배정/완료 관리자 알림)
데이터 저장은 project_db 전용이다. PROJECT_DB_URL 미설정
build_project_store None 반환하고, 라우터가 "설정 필요" 안내 페이지를
보여준다(앱은 죽지 않음).
권한: 로그인한 회사(dbxcorp.co.kr) 직원은 모듈 진입 가능. 프로젝트 생성/삭제·
사용자 배정은 ERP 관리자(is_admin). 배정된 멤버는 서브프로젝트/업무/단계 관리.
"""
from typing import Any
from . import store
from .router import router
__all__ = ["router", "store", "build_project_store"]
def build_project_store(*, dsn: str | None) -> Any:
"""PROJECT_DB_URL 이 있으면 ProjectStore, 없으면 None.
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시.
"""
if not dsn:
return None
from .db import ProjectStore # 지연 import (개발 환경 deps 없을 수 있음)
return ProjectStore(dsn)
+743
View File
@@ -0,0 +1,743 @@
"""project_db PostgreSQL 저장소.
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) 다른 모듈과 동일 패턴.
- 연결 정보: 환경변수 `PROJECT_DB_URL`
(: postgresql://project_app:<pwd>@postgres-db:5432/project_db)
- 스키마는 앱이 만들지 않는다. `scripts/sql/project_db_init.sql` superuser
사전 적용한다. 계정(project_app) CRUD 권한만 받는다.
- 연결 풀은 lazy open.
순수 검증/상수는 store.py 있고, 여기서는 DB I/O 직렬화만 담당한다.
"""
from __future__ import annotations
import logging
from datetime import date, datetime, time
from typing import Any
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from app.timezone import KST, now_kst
from . import store
logger = logging.getLogger("project.db")
class ProjectStore:
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_projects(
self, *, include_archived: bool = False, member_email: str | None = None
) -> list[dict[str, Any]]:
"""전체 프로젝트(서브 포함) 평면 목록. member_email 지정 시 그 사용자가
멤버이거나 owner 프로젝트만(관리자는 라우터에서 None 으로 전체 조회).
"""
clauses: list[str] = []
params: list[Any] = []
if not include_archived:
clauses.append("p.status <> 'archived'")
if member_email:
clauses.append(
"(p.owner_email = %s OR EXISTS ("
" SELECT 1 FROM project_members m "
" WHERE m.project_id = p.id AND m.user_email = %s))"
)
params.extend([member_email, member_email])
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT p.* FROM projects p {where} "
"ORDER BY p.parent_id NULLS FIRST, p.sort_order ASC, p.id ASC",
params,
).fetchall()
return [self._serialize(r) for r in rows]
def get_project(self, *, project_id: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM projects WHERE id = %s", (project_id,)
).fetchone()
return self._serialize(row) if row else None
def create_project(
self,
*,
name: str,
parent_id: int | None = None,
description: str = "",
color: str = "",
owner_email: str = "",
start_date: str | None = None,
due_date: str | None = None,
created_by: str = "",
seed_stages: bool = True,
) -> dict[str, Any]:
nm = store.validate_project_name(name)
col = store.validate_color(color)
sd = store.validate_date(start_date)
dd = store.validate_date(due_date)
with self._pool.connection() as conn:
with conn.transaction():
if parent_id is not None:
parent = conn.execute(
"SELECT id FROM projects WHERE id = %s", (parent_id,)
).fetchone()
if not parent:
raise KeyError(f"상위 프로젝트를 찾을 수 없습니다: {parent_id}")
row = conn.execute(
"""
INSERT INTO projects
(parent_id, name, description, color, owner_email,
start_date, due_date, created_by)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
parent_id,
nm,
store.norm_str(description),
col,
store.norm_str(owner_email, lower=True),
sd,
dd,
store.norm_str(created_by, lower=True),
),
).fetchone()
pid = row["id"]
if seed_stages:
for i, (stage_name, is_done) in enumerate(store.DEFAULT_STAGES):
conn.execute(
"INSERT INTO project_stages "
"(project_id, name, sort_order, is_done_stage) "
"VALUES (%s,%s,%s,%s)",
(pid, stage_name, i, is_done),
)
self._log(conn, project_id=pid, actor=created_by,
action=store.ACTION_CREATED, detail=nm)
return self._serialize(row)
def update_project(self, *, project_id: int, fields: dict[str, Any]) -> dict[str, Any]:
allowed = {
"name": lambda v: store.validate_project_name(v),
"description": lambda v: store.norm_str(v),
"color": lambda v: store.validate_color(v),
"owner_email": lambda v: store.norm_str(v, lower=True),
"start_date": lambda v: store.validate_date(v),
"due_date": lambda v: store.validate_date(v),
"status": lambda v: (store.norm_str(v, lower=True)
if store.norm_str(v, lower=True) in store.PROJECT_STATUSES
else "active"),
}
sets: list[str] = []
params: list[Any] = []
for key, fn in allowed.items():
if key in fields:
sets.append(f"{key} = %s")
params.append(fn(fields[key]))
if not sets:
existing = self.get_project(project_id=project_id)
if existing is None:
raise KeyError(project_id)
return existing
params.append(project_id)
with self._pool.connection() as conn:
row = conn.execute(
f"UPDATE projects SET {', '.join(sets)} WHERE id = %s RETURNING *",
params,
).fetchone()
if not row:
raise KeyError(project_id)
return self._serialize(row)
def delete_project(self, *, project_id: int) -> None:
"""프로젝트(서브프로젝트·단계·업무 CASCADE) 삭제."""
with self._pool.connection() as conn:
cur = conn.execute("DELETE FROM projects WHERE id = %s", (project_id,))
if cur.rowcount == 0:
raise KeyError(project_id)
# ════════════════════════════════════════════════════════════
# 멤버
# ════════════════════════════════════════════════════════════
def list_members(self, *, project_id: int) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM project_members WHERE project_id = %s "
"ORDER BY role DESC, user_email ASC",
(project_id,),
).fetchall()
return [self._serialize(r) for r in rows]
def is_member(self, *, project_id: int, user_email: str) -> bool:
email = store.norm_str(user_email, lower=True)
with self._pool.connection() as conn:
row = conn.execute(
"SELECT 1 FROM projects p "
"LEFT JOIN project_members m "
" ON m.project_id = p.id AND m.user_email = %s "
"WHERE p.id = %s AND (p.owner_email = %s OR m.id IS NOT NULL) LIMIT 1",
(email, project_id, email),
).fetchone()
return bool(row)
def add_member(
self, *, project_id: int, user_email: str, user_name: str = "", role: str = "member"
) -> dict[str, Any]:
email = store.norm_str(user_email, lower=True)
if not email or "@" not in email:
raise ValueError("올바른 이메일이 아닙니다.")
rl = store.validate_role(role)
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO project_members (project_id, user_email, user_name, role)
VALUES (%s,%s,%s,%s)
ON CONFLICT (project_id, user_email) DO UPDATE
SET role = EXCLUDED.role, user_name = EXCLUDED.user_name
RETURNING *
""",
(project_id, email, store.norm_str(user_name), rl),
).fetchone()
return self._serialize(row)
def remove_member(self, *, project_id: int, user_email: str) -> None:
email = store.norm_str(user_email, lower=True)
with self._pool.connection() as conn:
conn.execute(
"DELETE FROM project_members WHERE project_id = %s AND user_email = %s",
(project_id, email),
)
# ════════════════════════════════════════════════════════════
# 단계 (stages)
# ════════════════════════════════════════════════════════════
def list_stages(self, *, project_id: int) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM project_stages WHERE project_id = %s "
"ORDER BY sort_order ASC, id ASC",
(project_id,),
).fetchall()
return [self._serialize(r) for r in rows]
def add_stage(
self, *, project_id: int, name: str, is_done_stage: bool = False
) -> dict[str, Any]:
nm = store.norm_str(name)
if not nm:
raise ValueError("단계 이름은 필수입니다.")
with self._pool.connection() as conn:
nxt = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS n "
"FROM project_stages WHERE project_id = %s",
(project_id,),
).fetchone()
row = conn.execute(
"INSERT INTO project_stages (project_id, name, sort_order, is_done_stage) "
"VALUES (%s,%s,%s,%s) RETURNING *",
(project_id, nm, int(nxt["n"]), bool(is_done_stage)),
).fetchone()
return self._serialize(row)
def delete_stage(self, *, stage_id: int) -> None:
with self._pool.connection() as conn:
cur = conn.execute("DELETE FROM project_stages WHERE id = %s", (stage_id,))
if cur.rowcount == 0:
raise KeyError(stage_id)
# ════════════════════════════════════════════════════════════
# 업무 (tasks)
# ════════════════════════════════════════════════════════════
def list_tasks(
self,
*,
project_id: int | None = None,
assignee_email: str | None = None,
include_subprojects: bool = False,
limit: int = 1000,
) -> list[dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if project_id is not None:
if include_subprojects:
clauses.append(
"t.project_id IN ("
" WITH RECURSIVE tree AS ("
" SELECT id FROM projects WHERE id = %s "
" UNION ALL "
" SELECT p.id FROM projects p JOIN tree ON p.parent_id = tree.id"
" ) SELECT id FROM tree)"
)
params.append(project_id)
else:
clauses.append("t.project_id = %s")
params.append(project_id)
if assignee_email:
clauses.append("t.assignee_email = %s")
params.append(store.norm_str(assignee_email, lower=True))
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
params.append(int(limit))
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT t.*, p.name AS project_name, p.color AS project_color, "
f" s.name AS stage_name, s.is_done_stage, "
f" (SELECT COUNT(*) FROM task_comments c WHERE c.task_id = t.id) AS comment_count "
f"FROM tasks t "
f"JOIN projects p ON p.id = t.project_id "
f"LEFT JOIN project_stages s ON s.id = t.stage_id "
f"{where} "
"ORDER BY t.sort_order ASC, t.id ASC LIMIT %s",
params,
).fetchall()
return [self._serialize(r) for r in rows]
def get_task(self, *, task_id: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT t.*, p.name AS project_name, p.color AS project_color, "
" s.name AS stage_name, s.is_done_stage, "
" (SELECT COUNT(*) FROM task_comments c WHERE c.task_id = t.id) AS comment_count "
"FROM tasks t JOIN projects p ON p.id = t.project_id "
"LEFT JOIN project_stages s ON s.id = t.stage_id "
"WHERE t.id = %s",
(task_id,),
).fetchone()
return self._serialize(row) if row else None
def create_task(
self,
*,
project_id: int,
title: str,
stage_id: int | None = None,
description: str = "",
assignee_email: str = "",
assignee_name: str = "",
priority: str = "normal",
start_date: str | None = None,
due_date: str | None = None,
start_time: str | None = None,
due_time: str | None = None,
created_by: str = "",
) -> dict[str, Any]:
ttl = store.validate_task_title(title)
pr = store.validate_priority(priority)
sd = store.validate_date(start_date)
dd = store.validate_date(due_date)
sti = store.validate_time(start_time)
dti = store.validate_time(due_time)
assignee = store.norm_str(assignee_email, lower=True)
with self._pool.connection() as conn:
with conn.transaction():
# stage 미지정 시 프로젝트의 첫 단계로
if stage_id is None:
st = conn.execute(
"SELECT id FROM project_stages WHERE project_id = %s "
"ORDER BY sort_order ASC, id ASC LIMIT 1",
(project_id,),
).fetchone()
stage_id = st["id"] if st else None
nxt = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS n FROM tasks "
"WHERE project_id = %s",
(project_id,),
).fetchone()
row = conn.execute(
"""
INSERT INTO tasks
(project_id, stage_id, title, description, assignee_email,
assignee_name, priority, start_date, due_date,
start_time, due_time, sort_order, created_by)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
project_id, stage_id, ttl, store.norm_str(description),
assignee, store.norm_str(assignee_name), pr, sd, dd,
sti, dti, int(nxt["n"]), store.norm_str(created_by, lower=True),
),
).fetchone()
if assignee:
self._log(conn, project_id=project_id, task_id=row["id"],
actor=created_by, action=store.ACTION_ASSIGNED,
detail=f"{ttl}{assignee}")
return self.get_task(task_id=row["id"])
def update_task(
self, *, task_id: int, fields: dict[str, Any], actor: str = ""
) -> dict[str, Any]:
"""업무 수정. 반환에 알림 힌트를 담는다(라우터가 메일 발송 판단).
반환 dict 추가되는 :
_newly_assigned: 담당자 이메일 or None
_newly_completed: 완료로 전환됐으면 True
"""
prev = self.get_task(task_id=task_id)
if prev is None:
raise KeyError(task_id)
sets: list[str] = []
params: list[Any] = []
newly_assigned: str | None = None
newly_completed = False
if "title" in fields:
sets.append("title = %s")
params.append(store.validate_task_title(fields["title"]))
if "description" in fields:
sets.append("description = %s")
params.append(store.norm_str(fields["description"]))
if "priority" in fields:
sets.append("priority = %s")
params.append(store.validate_priority(fields["priority"]))
if "start_date" in fields:
sets.append("start_date = %s")
params.append(store.validate_date(fields["start_date"]))
if "due_date" in fields:
sets.append("due_date = %s")
params.append(store.validate_date(fields["due_date"]))
if "start_time" in fields:
sets.append("start_time = %s")
params.append(store.validate_time(fields["start_time"]))
if "due_time" in fields:
sets.append("due_time = %s")
params.append(store.validate_time(fields["due_time"]))
if "assignee_email" in fields:
new_assignee = store.norm_str(fields["assignee_email"], lower=True)
sets.append("assignee_email = %s")
params.append(new_assignee)
if "assignee_name" in fields:
sets.append("assignee_name = %s")
params.append(store.norm_str(fields["assignee_name"]))
if new_assignee and new_assignee != (prev.get("assignee_email") or ""):
newly_assigned = new_assignee
elif "assignee_name" in fields:
sets.append("assignee_name = %s")
params.append(store.norm_str(fields["assignee_name"]))
# 단계 변경 → 완료단계 진입 시 completed_at 자동 세팅
if "stage_id" in fields:
new_stage = fields["stage_id"]
sets.append("stage_id = %s")
params.append(new_stage)
done = self._stage_is_done(new_stage)
if done and not prev.get("completed_at"):
sets.append("completed_at = %s")
params.append(now_kst())
newly_completed = True
elif not done and prev.get("completed_at"):
sets.append("completed_at = NULL")
if not sets:
return {**prev, "_newly_assigned": None, "_newly_completed": False}
params.append(task_id)
with self._pool.connection() as conn:
with conn.transaction():
conn.execute(
f"UPDATE tasks SET {', '.join(sets)} WHERE id = %s", params
)
if newly_assigned:
self._log(conn, project_id=prev["project_id"], task_id=task_id,
actor=actor, action=store.ACTION_ASSIGNED,
detail=f"{prev['title']}{newly_assigned}")
if newly_completed:
self._log(conn, project_id=prev["project_id"], task_id=task_id,
actor=actor, action=store.ACTION_COMPLETED,
detail=prev["title"])
out = self.get_task(task_id=task_id)
out["_newly_assigned"] = newly_assigned
out["_newly_completed"] = newly_completed
return out
def delete_task(self, *, task_id: int) -> None:
with self._pool.connection() as conn:
cur = conn.execute("DELETE FROM tasks WHERE id = %s", (task_id,))
if cur.rowcount == 0:
raise KeyError(task_id)
def _stage_is_done(self, stage_id: Any) -> bool:
if stage_id is None:
return False
with self._pool.connection() as conn:
row = conn.execute(
"SELECT is_done_stage FROM project_stages WHERE id = %s", (stage_id,)
).fetchone()
return bool(row and row["is_done_stage"])
# ════════════════════════════════════════════════════════════
# 활동 이력
# ════════════════════════════════════════════════════════════
def list_activity(self, *, project_id: int, limit: int = 100) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM project_activity WHERE project_id = %s "
"ORDER BY created_at DESC, id DESC LIMIT %s",
(project_id, int(limit)),
).fetchall()
return [self._serialize(r) for r in rows]
@staticmethod
def _log(
conn: Any, *, project_id: int | None = None, task_id: int | None = None,
actor: str = "", action: str = "", detail: str = "",
) -> None:
conn.execute(
"INSERT INTO project_activity "
"(project_id, task_id, actor_email, action, detail) "
"VALUES (%s,%s,%s,%s,%s)",
(project_id, task_id, store.norm_str(actor, lower=True), action,
store.norm_str(detail)),
)
# ════════════════════════════════════════════════════════════
# 댓글 (task_comments)
# ════════════════════════════════════════════════════════════
def list_comments(self, *, task_id: int) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM task_comments WHERE task_id = %s "
"ORDER BY created_at ASC, id ASC",
(task_id,),
).fetchall()
return [self._serialize(r) for r in rows]
def add_comment(
self, *, task_id: int, author_email: str, author_name: str, body: str
) -> dict[str, Any]:
text = store.norm_str(body)
if not text:
raise ValueError("댓글 내용이 비었습니다.")
with self._pool.connection() as conn:
row = conn.execute(
"INSERT INTO task_comments (task_id, author_email, author_name, body) "
"VALUES (%s,%s,%s,%s) RETURNING *",
(task_id, store.norm_str(author_email, lower=True),
store.norm_str(author_name), text),
).fetchone()
return self._serialize(row)
def update_comment(
self, *, comment_id: int, requester: str, is_admin: bool, body: str
) -> dict[str, Any]:
text = store.norm_str(body)
if not text:
raise ValueError("댓글 내용이 비었습니다.")
with self._pool.connection() as conn:
row = conn.execute(
"SELECT author_email FROM task_comments WHERE id = %s", (comment_id,)
).fetchone()
if not row:
raise KeyError(comment_id)
if not is_admin and row["author_email"] != store.norm_str(requester, lower=True):
raise PermissionError("본인 댓글만 수정할 수 있습니다.")
out = conn.execute(
"UPDATE task_comments SET body = %s WHERE id = %s RETURNING *",
(text, comment_id),
).fetchone()
return self._serialize(out)
def delete_comment(self, *, comment_id: int, requester: str, is_admin: bool) -> None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT author_email FROM task_comments WHERE id = %s", (comment_id,)
).fetchone()
if not row:
raise KeyError(comment_id)
if not is_admin and row["author_email"] != store.norm_str(requester, lower=True):
raise PermissionError("본인 댓글만 삭제할 수 있습니다.")
conn.execute("DELETE FROM task_comments WHERE id = %s", (comment_id,))
# ════════════════════════════════════════════════════════════
# 첨부 (task_attachments) — 메타만 저장. 파일은 라우터가 디스크 처리.
# ════════════════════════════════════════════════════════════
def list_attachments(self, *, task_id: int) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM task_attachments WHERE task_id = %s "
"ORDER BY created_at DESC, id DESC",
(task_id,),
).fetchall()
return [self._serialize(r) for r in rows]
def add_attachment(
self, *, task_id: int, filename: str, stored_name: str,
content_type: str, size_bytes: int, uploaded_by: str,
) -> dict[str, Any]:
with self._pool.connection() as conn:
row = conn.execute(
"INSERT INTO task_attachments "
"(task_id, filename, stored_name, content_type, size_bytes, uploaded_by) "
"VALUES (%s,%s,%s,%s,%s,%s) RETURNING *",
(task_id, store.norm_str(filename), stored_name,
store.norm_str(content_type), int(size_bytes),
store.norm_str(uploaded_by, lower=True)),
).fetchone()
return self._serialize(row)
def get_attachment(self, *, attachment_id: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM task_attachments WHERE id = %s", (attachment_id,)
).fetchone()
return self._serialize(row) if row else None
def delete_attachment(self, *, attachment_id: int) -> dict[str, Any]:
"""삭제 후 행(파일 경로 정리용 stored_name 포함)을 반환."""
with self._pool.connection() as conn:
row = conn.execute(
"DELETE FROM task_attachments WHERE id = %s RETURNING *", (attachment_id,)
).fetchone()
if not row:
raise KeyError(attachment_id)
return self._serialize(row)
# ════════════════════════════════════════════════════════════
# 단계 순서/이름 편집
# ════════════════════════════════════════════════════════════
def update_stage(
self, *, stage_id: int, name: str | None = None,
is_done_stage: bool | None = None,
) -> dict[str, Any]:
sets: list[str] = []
params: list[Any] = []
if name is not None:
nm = store.norm_str(name)
if not nm:
raise ValueError("단계 이름은 필수입니다.")
sets.append("name = %s")
params.append(nm)
if is_done_stage is not None:
sets.append("is_done_stage = %s")
params.append(bool(is_done_stage))
if not sets:
raise ValueError("변경할 내용이 없습니다.")
params.append(stage_id)
with self._pool.connection() as conn:
row = conn.execute(
f"UPDATE project_stages SET {', '.join(sets)} WHERE id = %s RETURNING *",
params,
).fetchone()
if not row:
raise KeyError(stage_id)
return self._serialize(row)
def reorder_stages(self, *, project_id: int, ordered_ids: list[int]) -> None:
"""주어진 순서대로 sort_order 재배정. 해당 프로젝트 단계만 갱신."""
with self._pool.connection() as conn:
with conn.transaction():
for i, sid in enumerate(ordered_ids):
conn.execute(
"UPDATE project_stages SET sort_order = %s "
"WHERE id = %s AND project_id = %s",
(i, int(sid), project_id),
)
# ════════════════════════════════════════════════════════════
# 알림센터 (project_notifications)
# ════════════════════════════════════════════════════════════
def add_notification(
self, *, recipient_email: str, actor_email: str = "",
project_id: int | None = None, task_id: int | None = None,
type: str = "", title: str = "", body: str = "",
) -> None:
rcpt = store.norm_str(recipient_email, lower=True)
if not rcpt:
return
# 본인이 본인에게 보내는 알림은 생략(아사나도 자기 행동은 알림 안 함)
if rcpt == store.norm_str(actor_email, lower=True):
return
with self._pool.connection() as conn:
conn.execute(
"INSERT INTO project_notifications "
"(recipient_email, actor_email, project_id, task_id, type, title, body) "
"VALUES (%s,%s,%s,%s,%s,%s,%s)",
(rcpt, store.norm_str(actor_email, lower=True), project_id, task_id,
type, store.norm_str(title), store.norm_str(body)),
)
def list_notifications(
self, *, recipient_email: str, unread_only: bool = False, limit: int = 100
) -> list[dict[str, Any]]:
rcpt = store.norm_str(recipient_email, lower=True)
clause = "WHERE recipient_email = %s"
params: list[Any] = [rcpt]
if unread_only:
clause += " AND is_read = FALSE"
params.append(int(limit))
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM project_notifications {clause} "
"ORDER BY created_at DESC, id DESC LIMIT %s",
params,
).fetchall()
return [self._serialize(r) for r in rows]
def count_unread(self, *, recipient_email: str) -> int:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT COUNT(*) AS n FROM project_notifications "
"WHERE recipient_email = %s AND is_read = FALSE",
(store.norm_str(recipient_email, lower=True),),
).fetchone()
return int(row["n"] or 0)
def mark_read(self, *, notification_id: int, recipient_email: str) -> None:
with self._pool.connection() as conn:
conn.execute(
"UPDATE project_notifications SET is_read = TRUE "
"WHERE id = %s AND recipient_email = %s",
(notification_id, store.norm_str(recipient_email, lower=True)),
)
def mark_all_read(self, *, recipient_email: str) -> int:
with self._pool.connection() as conn:
cur = conn.execute(
"UPDATE project_notifications SET is_read = TRUE "
"WHERE recipient_email = %s AND is_read = FALSE",
(store.norm_str(recipient_email, lower=True),),
)
return cur.rowcount or 0
# ════════════════════════════════════════════════════════════
# 직렬화
# ════════════════════════════════════════════════════════════
@staticmethod
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
for k, v in list(out.items()):
if isinstance(v, datetime):
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
elif isinstance(v, time):
out[k] = v.strftime("%H:%M")
elif isinstance(v, date):
out[k] = v.isoformat()
for k in ("id", "parent_id", "project_id", "stage_id", "task_id", "sort_order"):
if k in out and out[k] is not None:
try:
out[k] = int(out[k])
except (TypeError, ValueError):
pass
for k in ("is_done_stage", "is_read"):
if k in out and out[k] is not None:
out[k] = bool(out[k])
return out
File diff suppressed because it is too large Load Diff
+136
View File
@@ -0,0 +1,136 @@
"""프로젝트 관리 모듈 — 상수 / 기본값 / 순수 검증 로직.
DB I/O db.py(ProjectStore) 담당하고, 여기서는 상수와 입력 검증
DB 없이 단위 테스트 가능한 순수 함수만 둔다(malaysia.store 패턴).
"""
from __future__ import annotations
from typing import Any
# 프로젝트 생성 시 자동으로 깔리는 기본 진행 단계(칸반 컬럼).
# (name, is_done_stage) — 마지막 '완료' 단계로 옮기면 업무 완료 처리.
DEFAULT_STAGES: tuple[tuple[str, bool], ...] = (
("할 일", False),
("진행 중", False),
("검토", False),
("완료", True),
)
# 업무 우선순위
PRIORITIES: tuple[str, ...] = ("low", "normal", "high")
PRIORITY_LABELS: dict[str, str] = {"low": "낮음", "normal": "보통", "high": "높음"}
# 프로젝트 상태
PROJECT_STATUSES: tuple[str, ...] = ("active", "completed", "archived")
# 멤버 역할
MEMBER_ROLES: tuple[str, ...] = ("manager", "member")
# 활동(activity) 종류 — 메일 트리거/타임라인 표시용
ACTION_CREATED = "created"
ACTION_ASSIGNED = "assigned"
ACTION_COMPLETED = "completed"
ACTION_STAGE_CHANGED = "stage_changed"
ACTION_REOPENED = "reopened"
# 프로젝트 색상 팔레트(아사나 느낌) — UI 선택지
COLOR_PALETTE: tuple[str, ...] = (
"#4573d2", # blue
"#37a3a3", # teal
"#62a420", # green
"#e8a33d", # amber
"#e8384f", # red
"#aa62e3", # purple
"#f06a6a", # coral
"#5a6772", # slate
)
def norm_str(s: Any, *, lower: bool = False) -> str:
out = str(s or "").strip()
return out.lower() if lower else out
def validate_priority(p: Any) -> str:
v = norm_str(p, lower=True)
return v if v in PRIORITIES else "normal"
def validate_role(r: Any) -> str:
v = norm_str(r, lower=True)
return v if v in MEMBER_ROLES else "member"
def validate_project_name(name: Any) -> str:
v = norm_str(name)
if not v:
raise ValueError("프로젝트 이름은 필수입니다.")
if len(v) > 200:
raise ValueError("프로젝트 이름이 너무 깁니다(200자 이내).")
return v
def validate_task_title(title: Any) -> str:
v = norm_str(title)
if not v:
raise ValueError("업무 제목은 필수입니다.")
if len(v) > 300:
raise ValueError("업무 제목이 너무 깁니다(300자 이내).")
return v
def validate_color(color: Any) -> str:
v = norm_str(color)
if not v:
return COLOR_PALETTE[0]
# 매우 단순한 hex 검증 — #RGB / #RRGGBB
if v.startswith("#") and len(v) in (4, 7):
try:
int(v[1:], 16)
return v.lower()
except ValueError:
pass
return COLOR_PALETTE[0]
def validate_date(d: Any) -> str | None:
"""'YYYY-MM-DD' 형식만 통과. 빈값은 None."""
from datetime import date as _date # noqa: WPS433
v = norm_str(d)
if not v:
return None
try:
_date.fromisoformat(v[:10])
except (ValueError, TypeError):
raise ValueError(f"날짜 형식이 올바르지 않습니다: {d}")
return v[:10]
def validate_time(t: Any) -> str | None:
"""'HH:MM' 형식만 통과(초는 버림). 빈값/None 은 None(=종일)."""
v = norm_str(t)
if not v:
return None
parts = v.split(":")
try:
hh = int(parts[0])
mm = int(parts[1]) if len(parts) > 1 else 0
except (ValueError, IndexError):
raise ValueError(f"시간 형식이 올바르지 않습니다: {t}")
if not (0 <= hh <= 23 and 0 <= mm <= 59):
raise ValueError(f"시간 범위가 올바르지 않습니다: {t}")
return f"{hh:02d}:{mm:02d}"
def build_subject_assigned(*, project_name: str, task_title: str, assignee: str) -> str:
return f"[DBX 프로젝트] 업무 배정: {project_name} · {task_title}{assignee}"
def build_subject_completed(*, project_name: str, task_title: str) -> str:
return f"[DBX 프로젝트] 업무 완료: {project_name} · {task_title}"
def build_subject_project_done(*, project_name: str) -> str:
return f"[DBX 프로젝트] 프로젝트 완료: {project_name}"
@@ -0,0 +1,49 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625p" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
{% block content %}
<div class="pj-wrap">
<div class="pj-section-head">
<h2>알림센터</h2>
<div style="display:flex; gap:8px;">
<a class="pj-btn" href="/project/">← 프로젝트</a>
<button type="button" class="pj-btn pj-btn-primary" id="pj-readall">모두 읽음</button>
</div>
</div>
{% if not notifications %}
<div class="pj-empty">알림이 없습니다.</div>
{% else %}
<ul class="pj-inbox" id="pj-inbox">
{% for n in notifications %}
<li class="pj-inbox-item {% if not n.is_read %}is-unread{% endif %}"
data-id="{{ n.id }}"
{% if n.task_id %}data-project="{{ n.project_id }}"{% endif %}>
<span class="material-symbols-outlined pj-inbox-icon">
{% if n.type == 'assigned' %}assignment_ind
{% elif n.type == 'completed' %}task_alt
{% elif n.type == 'comment' %}chat_bubble
{% else %}notifications{% endif %}
</span>
<div class="pj-inbox-body">
<div class="pj-inbox-title">{{ n.title }}</div>
{% if n.body %}<div class="pj-inbox-sub">{{ n.body }}</div>{% endif %}
<div class="pj-inbox-meta">
{% if n.actor_email %}{{ n.actor_email.split('@')[0] }} · {% endif %}{{ n.created_at[:16].replace('T',' ') }}
</div>
</div>
{% if n.project_id %}
<a class="pj-btn pj-inbox-go" href="/project/p/{{ n.project_id }}">이동</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
<script src="/static/project.js?v=20260625p" defer></script>
{% endblock %}
@@ -0,0 +1,171 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260626b" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
{% block content %}
<div class="pj-wrap" data-is-admin="{{ 'true' if is_admin else 'false' }}"
data-is-super="{{ 'true' if user.is_super_admin else 'false' }}"
data-me="{{ user.email }}">
<div class="pj-home">
<!-- 프로젝트 목록 -->
<section class="pj-home-main">
<div class="pj-section-head">
<h2>프로젝트</h2>
<div class="pj-toolbar-right">
{% set done_count = tree | selectattr('status', 'equalto', 'completed') | list | length %}
{% if done_count %}
<button type="button" class="pj-btn" id="pj-toggle-done" data-count="{{ done_count }}">
완료 {{ done_count }}개 숨기기
</button>
{% endif %}
<a class="pj-bell" href="/project/inbox" title="알림센터">
<span class="material-symbols-outlined">notifications</span>
<span class="pj-bell-badge" id="pj-bell-badge" hidden>0</span>
</a>
{% if is_admin %}
<button type="button" class="pj-btn pj-btn-primary" id="pj-new-project">
+ 새 프로젝트
</button>
{% endif %}
</div>
</div>
{% if not tree %}
<div class="pj-empty">
아직 프로젝트가 없습니다.
{% if is_admin %}상단의 <b>새 프로젝트</b> 버튼으로 만들어 보세요.
{% else %}관리자가 프로젝트에 배정하면 여기에 표시됩니다.{% endif %}
</div>
{% endif %}
<div class="pj-project-grid" id="pj-project-grid">
{% for p in tree %}
<div class="pj-project-card {% if p.status == 'completed' %}is-done{% endif %}"
style="--pj-color: {{ p.color }}"
data-id="{{ p.id }}"
data-name="{{ p.name }}"
data-desc="{{ p.description or '' }}"
data-start="{{ p.start_date or '' }}"
data-due="{{ p.due_date or '' }}"
data-color="{{ p.color }}"
data-status="{{ p.status or 'active' }}"
data-creator="{{ p.created_by or '' }}">
<a class="pj-project-cardlink" href="/project/p/{{ p.id }}">
<span class="pj-project-dot"></span>
<div class="pj-project-body">
<div class="pj-project-name">{{ p.name }}
{% if p.status == 'completed' %}<span class="pj-chip pj-chip-sm">완료</span>{% endif %}
</div>
{% if p.description %}<div class="pj-project-desc">{{ p.description }}</div>{% endif %}
<div class="pj-project-meta">
{% if p.due_date %}<span class="pj-chip">마감 {{ p.due_date }}</span>{% endif %}
</div>
</div>
</a>
{% if is_admin %}
<button type="button" class="pj-icon-btn pj-card-edit" title="프로젝트 수정">
<span class="material-symbols-outlined">edit</span>
</button>
{% endif %}
</div>
{% endfor %}
</div>
</section>
<!-- 내 업무 -->
<aside class="pj-home-side">
<div class="pj-section-head"><h2>내 업무</h2></div>
{% if not my_projects %}
<div class="pj-empty pj-empty-sm">배정된 업무가 없습니다.</div>
{% else %}
<div class="pj-mytree">
{% for p in my_projects %}
<div class="pj-mytree-group">
<a class="pj-mytree-proj" href="/project/p/{{ p.id }}">
<span class="material-symbols-outlined" style="color: {{ p.color }}; font-size:18px;">folder</span>
<span class="pj-mytree-proj-name">{{ p.name }}</span>
</a>
<ul class="pj-mytree-tasks">
{% for t in p.tasks %}
<li class="pj-mytree-task {% if t.completed_at %}is-done{% endif %}">
<a href="/project/p/{{ t.project_id }}?task={{ t.id }}">
<span class="material-symbols-outlined pj-mytree-ic" style="color: {{ t.project_color }};">{% if t.completed_at %}task_alt{% else %}radio_button_unchecked{% endif %}</span>
<span class="pj-mytree-body">
<span class="pj-mytree-title">{{ t.title }}</span>
{% if t.start_date or t.due_date %}
<span class="pj-mytree-dates">
{% if t.start_date and t.due_date %}{{ t.start_date }} ~ {{ t.due_date }}
{% elif t.due_date %}~ {{ t.due_date }}
{% else %}{{ t.start_date }} ~{% endif %}
</span>
{% endif %}
</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
{% endif %}
</aside>
</div>
</div>
<!-- 새 프로젝트 모달 -->
{% if is_admin %}
<div class="pj-modal" id="pj-modal-project" hidden>
<div class="pj-modal-card">
<h3>새 프로젝트</h3>
<label>이름<input type="text" id="pj-f-name" placeholder="프로젝트 이름" /></label>
<label>설명<textarea id="pj-f-desc" rows="2"></textarea></label>
<div class="pj-form-row">
<label>시작일<input type="date" id="pj-f-start" /></label>
<label>마감일<input type="date" id="pj-f-due" /></label>
</div>
<label>색상
<span class="pj-color-palette" id="pj-f-colors"></span>
</label>
<div class="pj-modal-actions">
<button type="button" class="pj-btn" data-close>취소</button>
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-project">만들기</button>
</div>
</div>
</div>
<!-- 프로젝트 수정 모달 -->
<div class="pj-modal" id="pj-modal-edit" hidden>
<div class="pj-modal-card">
<h3>프로젝트 수정</h3>
<input type="hidden" id="pj-e-id" />
<label>이름<input type="text" id="pj-e-name" placeholder="프로젝트 이름" /></label>
<label>설명<textarea id="pj-e-desc" rows="2"></textarea></label>
<div class="pj-form-row">
<label>시작일<input type="date" id="pj-e-start" /></label>
<label>마감일<input type="date" id="pj-e-due" /></label>
</div>
<label>색상
<span class="pj-color-palette" id="pj-e-colors"></span>
</label>
<label class="pj-time-toggle">
<input type="checkbox" id="pj-e-done" /> 프로젝트 완료 (목록 끝으로 이동·비활성)
</label>
<div class="pj-modal-actions">
<button type="button" class="pj-btn pj-btn-danger" id="pj-delete-project">삭제</button>
<span style="flex:1"></span>
<button type="button" class="pj-btn" data-close>취소</button>
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-edit">저장</button>
</div>
</div>
</div>
{% endif %}
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260626b" defer></script>
{% endblock %}
@@ -0,0 +1,295 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260626b" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host -->
<link href="/static/vendor/vis-timeline/vis-timeline-graph2d.min.css" rel="stylesheet" />
{% endblock %}
{% block topbar_actions %}
<a class="pj-bell" href="/project/inbox" title="알림센터">
<span class="material-symbols-outlined">notifications</span>
<span class="pj-bell-badge" id="pj-bell-badge" hidden>0</span>
</a>
{% if can_manage %}
<button type="button" class="pj-btn pj-btn-primary" id="pj-new-task">+ 업무 추가</button>
{% endif %}
{% endblock %}
{% block content %}
<div class="pj-wrap pj-project-view"
data-project-id="{{ project.id }}"
data-can-manage="{{ 'true' if can_manage else 'false' }}"
data-is-admin="{{ 'true' if is_admin else 'false' }}"
data-is-super="{{ 'true' if user.is_super_admin else 'false' }}"
data-me="{{ user.email }}">
<div class="pj-layout">
<!-- ── 좌측: 프로젝트 트리 + 멤버 ── -->
<aside class="pj-side">
<div class="pj-side-block">
<div class="pj-side-head">
<span>프로젝트</span>
{% if is_admin %}
<button type="button" class="pj-icon-btn" id="pj-new-project-side" title="새 프로젝트">+</button>
{% endif %}
</div>
{% if nav_projects %}
<ul class="pj-tree" id="pj-tree-root">
{% for p in nav_projects %}
<li class="pj-tree-li" data-id="{{ p.id }}">
<div class="pj-tree-row {% if p.id == project.id %}is-active{% endif %}">
{% if p.tasks %}
<button type="button" class="pj-tree-caret" aria-expanded="false" title="펼치기/접기">
<span class="material-symbols-outlined">chevron_right</span>
</button>
{% else %}
<span class="pj-tree-caret-spacer"></span>
{% endif %}
<a class="pj-tree-link" href="/project/p/{{ p.id }}">
<span class="pj-tree-icon material-symbols-outlined" style="color: {{ p.color }}">folder</span>
<span class="pj-tree-name">{{ p.name }}</span>
</a>
{% if is_admin %}
<span class="pj-tree-acts">
<button type="button" class="pj-icon-btn pj-tree-del" data-id="{{ p.id }}" data-name="{{ p.name }}" data-creator="{{ p.created_by }}" title="프로젝트 삭제">×</button>
</span>
{% endif %}
</div>
{% if p.tasks %}
<div class="pj-tree-children" hidden>
<ul class="pj-tree">
{% for t in p.tasks %}
<li class="pj-tree-li pj-tree-task">
<div class="pj-tree-row" style="--pj-indent: 1">
<span class="pj-tree-caret-spacer"></span>
<a class="pj-tree-link pj-tree-tasklink"
href="/project/p/{{ p.id }}?task={{ t.id }}"
data-project="{{ p.id }}" data-task="{{ t.id }}">
<span class="pj-tree-icon material-symbols-outlined" style="color: {{ t.color }}">{% if t.completed %}task_alt{% else %}radio_button_unchecked{% endif %}</span>
<span class="pj-tree-name {% if t.completed %}pj-tree-done{% endif %}">{{ t.title }}</span>
</a>
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<div class="pj-empty-sm">프로젝트 없음</div>
{% endif %}
</div>
<div class="pj-side-block">
<div class="pj-side-head">
<span>멤버</span>
{% if is_admin %}
<button type="button" class="pj-icon-btn" id="pj-add-member" title="멤버 배정">+</button>
{% endif %}
</div>
<ul class="pj-member-list" id="pj-member-list">
{% for m in members %}
<li>
<span class="material-symbols-outlined pj-gicon">account_circle</span>
<span class="pj-member-name">{{ m.user_email.split('@')[0] }}</span>
{% if m.role == 'manager' %}<span class="pj-chip pj-chip-sm">관리</span>{% endif %}
{% if is_admin %}
<button type="button" class="pj-icon-btn pj-del-member" data-email="{{ m.user_email }}" title="제외">×</button>
{% endif %}
</li>
{% else %}
<li class="pj-empty-sm">없음</li>
{% endfor %}
</ul>
</div>
</aside>
<!-- ── 우측: 뷰 ── -->
<section class="pj-main">
<div class="pj-toolbar">
<div class="pj-view-tabs" id="pj-view-tabs">
<button type="button" class="pj-tab is-active" data-view="calendar">달력</button>
<button type="button" class="pj-tab" data-view="timeline">타임라인</button>
<button type="button" class="pj-tab" data-view="board">보드</button>
<button type="button" class="pj-tab" data-view="list">리스트</button>
</div>
</div>
<!-- 달력 (휴가 모듈과 동일한 월간 그리드) -->
<div class="pj-view" data-view="calendar">
<div class="pj-cal-head">
<a class="pj-btn pj-nav-btn" href="/project/p/{{ project.id }}?y={{ prev_y }}&m={{ prev_m }}"></a>
<h2 class="pj-cal-title">{{ year }}년 {{ month }}월</h2>
<a class="pj-btn pj-nav-btn" href="/project/p/{{ project.id }}?y={{ next_y }}&m={{ next_m }}"></a>
<a class="pj-btn pj-today-btn" href="/project/p/{{ project.id }}">오늘</a>
</div>
<div class="pj-cal">
<div class="pj-wd-row">
{% for wd in weekdays %}
<div class="pj-wd {% if loop.index0 == 0 %}pj-red{% elif loop.index0 == 6 %}pj-blue{% endif %}">{{ wd }}</div>
{% endfor %}
</div>
{% for week in weeks %}
<div class="pj-week">
<div class="pj-week-days">
{% for cell in week.days %}
<div class="pj-day {% if not cell.in_month %}pj-out{% endif %} {% if cell.is_today %}pj-today{% endif %}"
data-date="{{ cell.date }}">
<span class="pj-day-num
{% if cell.is_sunday %}pj-red{% elif cell.is_saturday %}pj-blue{% endif %}">{{ cell.day }}</span>
</div>
{% endfor %}
</div>
<!-- 막대는 클라이언트(JS)가 그린다 — 깔끔한 칩 + 행 제한(+N) + 드래그 이동 -->
<div class="pj-week-bars"></div>
</div>
{% endfor %}
</div>
</div>
<!-- 타임라인 -->
<div class="pj-view" data-view="timeline" hidden>
<div id="pj-timeline"></div>
</div>
<!-- 보드(칸반) -->
<div class="pj-view" data-view="board" hidden>
<div class="pj-board" id="pj-board"></div>
</div>
<!-- 리스트 -->
<div class="pj-view" data-view="list" hidden>
<table class="pj-table" id="pj-list">
<thead>
<tr><th>업무</th><th>담당자</th><th>단계</th><th>우선순위</th><th>시작</th><th>마감</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
</div>
</div>
<!-- 업무 모달 -->
<div class="pj-modal" id="pj-modal-task" hidden>
<div class="pj-modal-card">
<h3 id="pj-task-modal-title">새 업무</h3>
<input type="hidden" id="pj-t-id" />
<label>제목<input type="text" id="pj-t-title" placeholder="업무 제목" /></label>
<label>설명<textarea id="pj-t-desc" rows="2"></textarea></label>
<div class="pj-form-row">
<label>담당자
<select id="pj-t-assignee">
<option value="">(미지정)</option>
{% for m in members %}
<option value="{{ m.user_email }}" data-name="{{ m.user_email.split('@')[0] }}">
{{ m.user_email.split('@')[0] }}
</option>
{% endfor %}
</select>
</label>
<label>단계
<select id="pj-t-stage">
{% for s in stages %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</label>
</div>
<div class="pj-form-row">
<label>우선순위
<select id="pj-t-priority">
<option value="low">낮음</option>
<option value="normal" selected>보통</option>
<option value="high">높음</option>
</select>
</label>
<label>시작일<input type="date" id="pj-t-start" /></label>
<label>마감일<input type="date" id="pj-t-due" /></label>
</div>
<label class="pj-time-toggle">
<input type="checkbox" id="pj-t-usetime" /> 시간 지정 (체크 안 하면 종일)
</label>
<div class="pj-form-row" id="pj-time-row">
<label>시작 시간<input type="time" id="pj-t-stime" disabled /></label>
<label>마감 시간<input type="time" id="pj-t-dtime" disabled /></label>
</div>
<!-- 첨부파일 (기존 업무 편집 시에만) -->
<div class="pj-task-extra" id="pj-attach-block" hidden>
<div class="pj-extra-head">
<span class="material-symbols-outlined pj-gicon pj-gicon-sm">attach_file</span> 첨부파일
<label class="pj-upload-btn">
파일 추가<input type="file" id="pj-attach-input" hidden />
</label>
</div>
<ul class="pj-attach-list" id="pj-attach-list"></ul>
</div>
<!-- 댓글 (기존 업무 편집 시에만) -->
<div class="pj-task-extra" id="pj-comment-block" hidden>
<div class="pj-extra-head">
<span class="pj-cmt-emoji">💬</span> 댓글
</div>
<ul class="pj-comment-list" id="pj-comment-list"></ul>
<div class="pj-comment-form">
<input type="text" id="pj-comment-input" placeholder="댓글 입력…" />
<button type="button" class="pj-btn pj-btn-primary" id="pj-comment-send">등록</button>
</div>
</div>
<div class="pj-modal-actions">
<button type="button" class="pj-btn pj-btn-danger" id="pj-delete-task" hidden>삭제</button>
<span style="flex:1"></span>
<button type="button" class="pj-btn" data-close>취소</button>
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-task">저장</button>
</div>
</div>
</div>
{% if is_admin %}
<!-- 멤버 배정 모달 — 등록된 사용자(프로젝트 권한 보유)를 자동 목록화 -->
<div class="pj-modal" id="pj-modal-member" hidden>
<div class="pj-modal-card">
<h3>멤버 배정</h3>
<p class="pj-modal-hint">관리자 페이지에서 <b>프로젝트 관리</b> 권한을 켠 직원이 후보로 나옵니다.</p>
<label>직원
<select id="pj-m-user"><option value="">불러오는 중…</option></select>
</label>
<label>역할
<select id="pj-m-role">
<option value="member" selected>멤버</option>
<option value="manager">관리(서브·업무 관리)</option>
</select>
</label>
<div class="pj-modal-actions">
<span style="flex:1"></span>
<button type="button" class="pj-btn" data-close>취소</button>
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-member">배정</button>
</div>
</div>
</div>
{% endif %}
<!-- 데이터 -->
<script id="pj-data" type="application/json">
{
"project": {{ project | tojson }},
"stages": {{ stages | tojson }},
"tasks": {{ tasks | tojson }},
"members": {{ members | tojson }},
"priorityLabels": {{ priority_labels | tojson }},
"projects": {{ projects_meta | tojson }},
"stagesByProject": {{ stages_by_project | tojson }},
"boardStages": {{ board_stage_names | tojson }},
"avatars": {{ avatars | tojson }}
}
</script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260626b" defer></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 %}
+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();
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122 28" role="img" aria-label="J&T Express">
<rect x="0.6" y="0.6" width="120.8" height="26.8" rx="6" fill="#fff" stroke="#E2231A" stroke-width="1.2"/>
<text x="9" y="20" font-family="Arial, 'Helvetica Neue', sans-serif" font-size="16"
font-weight="800" fill="#E2231A" letter-spacing="0.2">J&amp;T</text>
<text x="42" y="19" font-family="Arial, 'Helvetica Neue', sans-serif" font-size="11"
font-weight="700" fill="#1f2937" letter-spacing="1">EXPRESS</text>
</svg>

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 138 28" role="img" aria-label="Ninja Van">
<rect x="0" y="0" width="138" height="28" rx="6" fill="#D6242C"/>
<!-- 간이 닌자 머리(흰 원 + 빨강 눈띠) -->
<g transform="translate(14,14)">
<circle r="9" fill="#fff"/>
<rect x="-9" y="-2.4" width="18" height="4.8" fill="#D6242C"/>
<rect x="-7" y="-1.1" width="4.2" height="2.2" rx="1.1" fill="#fff"/>
<rect x="2.8" y="-1.1" width="4.2" height="2.2" rx="1.1" fill="#fff"/>
</g>
<text x="29" y="19" font-family="Arial, 'Helvetica Neue', sans-serif" font-size="14"
font-weight="700" fill="#fff" letter-spacing="0.3">Ninja Van</text>
</svg>

After

Width:  |  Height:  |  Size: 681 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+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 };
})();
+41 -17
View File
@@ -9,13 +9,15 @@
--erp-topbar-h: 56px;
}
/* ── 페이지 전체 컨테이너 ── */
body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
/* 페이지 전체 컨테이너
뷰포트(: 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;
min-height: 100vh;
height: 100vh;
transition: grid-template-columns .18s ease;
}
.erp-app:has(.erp-sidebar[data-collapsed="true"]) {
@@ -66,7 +68,7 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
}
.erp-sidebar-group {
font-size: 11px; font-weight: 600;
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);
@@ -74,18 +76,19 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
.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: 10px;
padding: 8px var(--sp-12);
display: flex; align-items: center; gap: 12px;
padding: 11px var(--sp-12);
border-radius: 10px;
color: var(--color-rich-black);
font-size: 14px; font-weight: 500;
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;
}
.erp-sidebar-item:hover:not(.is-disabled) { background: var(--color-ghost-gray); }
/* 실행중(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);
@@ -95,10 +98,11 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
cursor: not-allowed;
}
.erp-sidebar-icon {
width: 18px; height: 18px;
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);
@@ -110,12 +114,19 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
}
.erp-sidebar-ext { color: var(--color-midtone-gray); display: inline-flex; }
.erp-sidebar[data-collapsed="true"] .erp-sidebar-label,
.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 { justify-content: center; padding: 8px 0; }
/* 접힌 상태: 아이콘 위 / 프로그램 이름 작은 글씨 아래 */
.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);
@@ -125,10 +136,10 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
}
/* ── 콘텐츠 영역 ── */
.erp-content { display: flex; flex-direction: column; min-width: 0; }
.erp-content { display: flex; flex-direction: column; min-width: 0; height: 100vh; min-height: 0; }
.erp-topbar {
height: var(--erp-topbar-h);
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);
@@ -156,8 +167,14 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
.erp-page {
padding: var(--sp-24);
max-width: 1280px; width: 100%; margin: 0 auto;
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); }
@@ -259,7 +276,7 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
.erp-table tbody td {
padding: 10px var(--sp-16);
border-bottom: 1px solid var(--color-subtle-ash);
vertical-align: top;
vertical-align: middle;
}
.erp-table tbody tr:last-child td { border-bottom: 0; }
.erp-table tbody tr:hover { background: var(--color-ghost-gray); }
@@ -268,16 +285,23 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
/* ── 반응형 ── */
@media (max-width: 900px) {
.erp-app { grid-template-columns: 0 1fr; }
/* 모바일: grid 자체 해제(데스크탑 collapsed :has 규칙과의 특이도 싸움 회피).
사이드바는 흐름 떠있는 오버레이, 본문은 풀폭 block. */
body.erp-app-body { height: auto; overflow: auto; }
.erp-app,
.erp-app:has(.erp-sidebar[data-collapsed="true"]) { display: block; }
.erp-sidebar {
position: fixed; left: 0; top: 0; height: 100vh;
position: fixed; left: 0; top: 0; height: 100vh; z-index: 1000;
width: var(--erp-sidebar-w);
transform: translateX(-100%);
transition: transform .2s ease;
box-shadow: 0 0 0 1px var(--color-subtle-ash);
}
/* 모바일선 데스크탑 접힘 무시 — 메뉴 열면 풀폭으로 */
.erp-sidebar[data-collapsed="true"] { width: var(--erp-sidebar-w); }
.erp-sidebar.is-open { transform: translateX(0); }
.erp-sidebar-mobile-toggle { display: inline-flex; }
.erp-content { width: 100%; min-width: 0; }
.erp-form-grid { grid-template-columns: 1fr 1fr; }
.erp-field-wide { grid-column: span 2; }
}
+8
View File
@@ -197,6 +197,14 @@ body.erp-body {
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 {
background: transparent;
color: var(--color-rich-black);
+191
View File
@@ -0,0 +1,191 @@
/* / .
*
* - UI 고정 문구: 아래 KO_EN 사전으로 치환(요소 텍스트 = 한글 ).
* 대상 셀렉터는 UI 크롬(제목/헤더/버튼/라벨/배지/옵션/안내문)으로 한정
* 데이터 (상품코드/수량) 건드리지 않음.
* - 상품명: 서버가 data-ko / data-en 함께 렌더 언어에 맞게 textContent 교체.
* - 선택값은 localStorage('mys_lang') 저장, 모든 화면 공통 적용.
*/
(function () {
"use strict";
var KO_EN = {
// 사이드바 메뉴 / 페이지 제목
"말레이시아 재고관리": "Malaysia Stock",
"입고/출고/조정 일괄 등록": "IN/OUT/ADJUST bulk",
// 탭 / 공통
"재고 현황": "Stock Status",
"입출고": "In / Out",
"일일 재고조사": "Daily Stocktake",
"창고 랙 보기": "Rack View",
"창고 랙": "Warehouse Rack",
"랙 보기": "Rack View",
"인쇄 미리보기": "Print Preview",
"인쇄": "Print",
"저장": "Save",
"닫기": "Close",
"칸을 드래그해 다른 칸으로 옮기면 구성이 이동(채워진 칸끼리는 맞교환)됩니다. 칸 번호는 그대로입니다. 인쇄할 칸은 체크 후 인쇄하세요.":
"Drag a cell onto another to move its contents (filled cells swap). Cell numbers stay. Check cells, then print.",
"기준 재고조사": "Based on stocktake",
"표시할 재고조사가 없습니다.": "No stocktake to display.",
"박스": "box",
"개": "ea",
"입수량": "Units/box",
"합계": "Subtotal",
"창고": "Warehouse",
"날짜": "Date",
"메모": "Memo",
"상태": "Status",
// 재고현황
"전산 재고 현황 — 낱개 기준": "System Stock — by Single",
"현재고 = 입고 − 출고 + 조정 + 재고조사반영 (콤보는 낱개로 분해 반영됨)":
"Current = IN OUT + ADJUST + Stocktake (combos exploded to singles)",
"전산재고": "System Qty",
"조사수량": "Stocktake Qty",
"차이": "Diff",
"콤보 재고 (세트 단위)": "Combo Stock (by set)",
"콤보재고 = 재고조사": "Combo stock = stocktake",
" 이후 출고": " OUT since",
"재고조사 입력 없음": "No stocktake input",
"표시할 재고가 없습니다.": "No stock to display.",
"콤보 재고 입력이 없습니다.": "No combo stock input.",
"위험 구역 (슈퍼관리자)": "Danger Zone (Super Admin)",
"재고 초기화": "Reset Stock",
// 입출고
"등록 옵션": "Options",
"이동 종류 (낱개)": "Type (single)",
"IN (입고)": "IN (In)",
"OUT (출고)": "OUT (Out)",
"ADJUST (조정 ±)": "ADJUST (±)",
"입력": "Input",
"다운로드": "Download",
"낱개 상품": "Single Products",
"콤보 상품": "Combo Products",
"이동 이력": "Movement History",
"구분": "Kind",
"낱개": "Single",
"콤보": "Combo",
"전체": "All",
"날짜해제": "Clear date",
"이동 이력이 없습니다.": "No movements.",
// 재고조사 목록
"재고조사 생성": "New Stocktake",
"조사 날짜": "Date",
"생성": "Create",
"생성 후 상세에서 낱개/세트 수량을 입력하고 확정하세요.":
"After creating, enter single/set qty in detail and finalize.",
"조사 목록": "Stocktake List",
"열기": "Open",
"삭제": "Delete",
"재고조사가 없습니다.": "No stocktakes.",
"확정": "Final",
"취소": "Cancel",
"작성중": "Draft",
// 재고조사 상세
"재고조사": "Stocktake",
"◀◀ 조사 목록": "◀◀ List",
"확정시각": "Finalized at",
"이전값 불러오기": "Load previous",
"입력 저장": "Save",
"확정 (전산 반영)": "Finalize (apply)",
"삭제 (슈퍼관리자)": "Delete (super)",
"랙 입력 (박스 단위)": "Rack Input (by box)",
"랙 칸에 아이템·입수량(박스당 개수)·박스 수를 입력하면 낱개/콤보 수량이 자동 집계됩니다. 한 칸에 여러 아이템은 + 로 추가하세요.":
"Enter item · units-per-box · box count in each rack cell; single/combo qty is auto-summed. Use + to add more items in one cell.",
"최종 계산 (낱개 기준)": "Final Calc (by single)",
"total_qty = direct_qty(낱개 직접) + from_set_qty(콤보 분해)":
"total_qty = direct_qty (single) + from_set_qty (combo)",
"입력된 수량이 없습니다.": "No quantities entered.",
// 표 헤더(한글만)
"이동일": "Date",
// 창고 랙 / POG
"POG 출력": "POG Print",
"창고 POG 미리보기": "Warehouse POG Preview",
};
var EN_KO = {};
Object.keys(KO_EN).forEach(function (k) { EN_KO[KO_EN[k]] = k; });
// 부분치환용 키 — 긴 키부터(부분 매칭 충돌 방지)
var RICH_KEYS = Object.keys(KO_EN).sort(function (a, b) { return b.length - a.length; });
// 헤더/사이드바처럼 .mys 밖이면서 KO 문구가 다른 텍스트와 섞인 요소.
// 사이드바는 현재 활성(말레이시아) 항목만 번역 → 다른 메뉴는 그대로 유지.
var RICH_SEL = [
".erp-topbar-title h1",
".erp-topbar-title p",
".erp-sidebar-item.is-active .erp-sidebar-label",
].join(",");
function applyRich(lang) {
document.querySelectorAll(RICH_SEL).forEach(function (el) {
if (el.getAttribute("data-o") === null) {
el.setAttribute("data-o", (el.textContent || "").trim());
}
var ko = el.getAttribute("data-o");
if (lang !== "en") { el.textContent = ko; return; }
var s = ko;
RICH_KEYS.forEach(function (k) {
if (s.indexOf(k) !== -1) s = s.split(k).join(KO_EN[k]);
});
el.textContent = s;
});
}
var SEL = [
".mys h1", ".mys h2", ".mys h3",
".mys th",
".mys label > span",
".mys button",
".mys a.erp-btn",
".mys .erp-muted",
".mys option",
".mys .erp-badge",
".mys .i18n",
].join(",");
function applyLang(lang) {
// 1) 상품명(한/영 동시 보유)
document.querySelectorAll(".mys [data-ko][data-en]").forEach(function (el) {
el.textContent = (lang === "en") ? el.getAttribute("data-en")
: el.getAttribute("data-ko");
});
// 2) UI 고정 문구
document.querySelectorAll(SEL).forEach(function (el) {
// 자식 요소가 있는 컨테이너는 건너뜀(.i18n 로 명시한 경우만 허용)
if (el.children.length && !el.classList.contains("i18n")) return;
if (el.hasAttribute("data-ko") && el.hasAttribute("data-en")) return; // 이미 처리됨
if (el.getAttribute("data-o") === null) {
el.setAttribute("data-o", (el.textContent || "").trim());
}
var ko = el.getAttribute("data-o");
if (lang === "en") {
if (KO_EN[ko] !== undefined) el.textContent = KO_EN[ko];
} else {
el.textContent = ko;
}
});
// 3) 헤더/사이드바(부분 치환)
applyRich(lang);
// 4) 토글 버튼 라벨
var btn = document.getElementById("mys-lang-toggle");
if (btn) btn.textContent = (lang === "en") ? "한글" : "ENG";
document.documentElement.setAttribute("data-mys-lang", lang);
}
function current() {
return localStorage.getItem("mys_lang") === "en" ? "en" : "ko";
}
function toggle() {
var next = current() === "en" ? "ko" : "en";
localStorage.setItem("mys_lang", next);
applyLang(next);
}
document.addEventListener("DOMContentLoaded", function () {
var btn = document.getElementById("mys-lang-toggle");
if (btn) btn.addEventListener("click", toggle);
applyLang(current());
});
})();
+69
View File
@@ -0,0 +1,69 @@
/* (rack) .
*
* - (.rack-cell) 헤더의 + 버튼: 입력행을 칸에 추가(템플릿 #rk-row-tpl 클론).
* 클론 숨김필드 rk_cell 해당 코드를 채워 서버 병렬배열과 정렬.
* - 행의 × 버튼: 제거(칸에 행이 0개여도 무방 서버가 스킵).
* - 입수량/박스수 입력 소계(rk-sub) 실시간 갱신.
* ( 합계는 서로 다른 상품을 더하게 되어 의미 없으므로 표시하지 않음.)
* 저장은 제출(서버에서 SKU별 집계 재고조사 라인 재생성).
*/
(function () {
"use strict";
function num(el) {
var v = parseInt((el && el.value) || "0", 10);
return isNaN(v) || v < 0 ? 0 : v;
}
function comma(n) {
return n.toLocaleString("en-US");
}
function recalcRow(row) {
var upb = num(row.querySelector(".rk-upb"));
var box = num(row.querySelector(".rk-box"));
var sub = row.querySelector(".rk-sub");
if (sub) sub.textContent = comma(upb * box);
}
function wireRow(row) {
row.querySelectorAll(".rk-upb, .rk-box").forEach(function (inp) {
inp.addEventListener("input", function () {
recalcRow(row);
});
});
var del = row.querySelector(".rk-del");
if (del) {
del.addEventListener("click", function () {
row.remove();
});
}
}
function addRow(cell) {
var tpl = document.getElementById("rk-row-tpl");
if (!tpl) return;
var frag = tpl.content.cloneNode(true);
var row = frag.querySelector(".rk-row");
if (!row) return;
var hidden = row.querySelector('input[name="rk_cell"]');
if (hidden) hidden.value = cell.getAttribute("data-cell") || "";
cell.querySelector(".rk-rows").appendChild(row);
wireRow(row);
var sku = row.querySelector(".rk-sku");
if (sku) sku.focus();
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".rack-cell .rk-row").forEach(function (row) {
wireRow(row);
recalcRow(row);
});
document.querySelectorAll(".rack-cell .rk-add").forEach(function (btn) {
btn.addEventListener("click", function () {
var cell = btn.closest(".rack-cell");
if (cell) addRow(cell);
});
});
});
})();
+296
View File
@@ -0,0 +1,296 @@
/* + + .
*
* - .rv-cell data-entries(JSON) 진실원천. 드래그 드롭으로 칸의
* 구성(entries) 맞교환( 칸이면 이동). 번호(cell) 그대로.
* - 저장: 모든 칸의 현재 구성을 rk_cell/rk_sku/rk_upb/rk_box 병렬배열 hidden
* 으로 만들어 POST /malaysia/rack/save (서버 reposition_rack).
* - 인쇄: 체크한 칸을 칸마다 A4 가로 1장으로 미리보기 window.print().
* - 언어: localStorage('mys_lang'). /미리보기 텍스트를 현재 언어로 렌더.
*/
(function () {
"use strict";
function lang() { return localStorage.getItem("mys_lang") === "en" ? "en" : "ko"; }
function t(ko, en) { return lang() === "en" ? en : ko; }
function fmt(n) { return Number(n || 0).toLocaleString(); }
function nameOf(e) { return (lang() === "en" ? e.en : e.ko) || e.sku; }
// 상품명 끝의 용량 토큰(숫자 포함, 예 750ml)을 분리 → {main, size}
function splitName(full) {
var m = String(full || "").match(/^(.*\S)\s+(\S*\d\S*)$/);
if (m) return { main: m[1], size: m[2] };
return { main: full || "", size: "" };
}
function getEntries(cell) {
try { return JSON.parse(cell.getAttribute("data-entries") || "[]"); }
catch (_) { return []; }
}
function setEntries(cell, arr) {
cell.setAttribute("data-entries", JSON.stringify(arr));
}
function renderCell(cell) {
var items = cell.querySelector(".rv-items");
if (!items) return;
var arr = getEntries(cell);
items.innerHTML = "";
if (!arr.length) {
cell.classList.add("is-empty");
var s = document.createElement("span");
s.className = "rv-empty-note";
s.textContent = "—";
items.appendChild(s);
return;
}
cell.classList.remove("is-empty");
arr.forEach(function (e) {
var total = (Number(e.upb) || 0) * (Number(e.box) || 0);
var wrap = document.createElement("div");
wrap.className = "rv-item";
var nm = document.createElement("span");
nm.className = "rv-name";
nm.textContent = nameOf(e);
var calc = document.createElement("span");
calc.className = "rv-calc";
calc.innerHTML = fmt(e.upb) + t("개", "ea") + " × " + fmt(e.box) + t("박스", "box")
+ " = <span class=\"rv-qty\">" + fmt(total) + "</span>";
wrap.appendChild(nm);
wrap.appendChild(document.createElement("br"));
wrap.appendChild(calc);
items.appendChild(wrap);
});
}
function renderAll() {
document.querySelectorAll(".rv-cell").forEach(renderCell);
}
// ── 드래그 재배치 ──
var dragSrc = null;
var dirty = false;
function markDirty() {
dirty = true;
var b = document.getElementById("rv-save-btn");
if (b) b.classList.add("is-dirty");
}
function cleanup() {
if (dragSrc) dragSrc.classList.remove("dragging");
dragSrc = null;
document.querySelectorAll(".rv-cell.is-dragover")
.forEach(function (c) { c.classList.remove("is-dragover"); });
}
function onDragStart(e) {
var cell = e.target.closest(".rv-cell");
if (!cell) return;
dragSrc = cell;
cell.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", cell.getAttribute("data-cell")); } catch (_) {}
}
function onDragOver(e) {
if (!dragSrc) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
var cell = e.target.closest(".rv-cell");
if (cell && cell !== dragSrc) cell.classList.add("is-dragover");
}
function onDragLeave(e) {
var cell = e.target.closest(".rv-cell");
if (cell) cell.classList.remove("is-dragover");
}
function onDrop(e) {
e.preventDefault();
var dst = e.target.closest(".rv-cell");
if (!dst || !dragSrc || dst === dragSrc) { cleanup(); return; }
var a = getEntries(dragSrc);
var b = getEntries(dst);
setEntries(dragSrc, b);
setEntries(dst, a);
renderCell(dragSrc);
renderCell(dst);
markDirty();
cleanup();
}
// ── 인쇄 선택(체크박스) → 셀 강조 ──
function onChange(e) {
var cb = e.target;
if (!cb.classList || !cb.classList.contains("rv-pick")) return;
var cell = cb.closest(".rv-cell");
if (cell) cell.classList.toggle("is-selected", cb.checked);
}
// ── 저장 ──
function addHidden(parent, name, val) {
var i = document.createElement("input");
i.type = "hidden";
i.name = name;
i.value = val;
parent.appendChild(i);
}
function save() {
var fields = document.getElementById("rv-save-fields");
var form = document.getElementById("rv-save-form");
if (!fields || !form) return;
fields.innerHTML = "";
document.querySelectorAll(".rv-cell").forEach(function (cell) {
var cc = cell.getAttribute("data-cell");
getEntries(cell).forEach(function (e) {
addHidden(fields, "rk_cell", cc);
addHidden(fields, "rk_sku", e.sku);
addHidden(fields, "rk_upb", e.upb);
addHidden(fields, "rk_box", e.box);
});
});
dirty = false;
var b = document.getElementById("rv-save-btn");
if (b) b.classList.remove("is-dirty");
alert(t("저장되었습니다.", "Saved."));
form.submit();
}
// ── 인쇄 미리보기 ──
function openPreview() {
var picked = [].slice.call(document.querySelectorAll(".rv-cell")).filter(function (c) {
var cb = c.querySelector(".rv-pick");
return cb && cb.checked;
});
if (!picked.length) {
alert(t("인쇄할 칸을 먼저 선택하세요.", "Select cells to print first."));
return;
}
// 빈 칸은 인쇄 대상에서 제외. 내용 있는 칸만 인쇄.
var withItems = picked.filter(function (c) { return getEntries(c).length > 0; });
if (!withItems.length) {
alert(t("인쇄할 문서가 없습니다. (선택한 칸이 모두 비어 있습니다)",
"Nothing to print. (All selected cells are empty.)"));
return;
}
var pages = document.getElementById("rvp-pages");
pages.innerHTML = "";
withItems.forEach(function (cell) {
var arr = getEntries(cell);
var sheet = document.createElement("div");
sheet.className = "rvp-sheet";
// 인쇄 시 칸에 담긴 상품 수에 따라 글자 크기 단계 지정(한 장에 다 들어가게)
if (arr.length >= 3) sheet.classList.add("rvp-many");
else if (arr.length === 2) sheet.classList.add("rvp-multi");
var list = document.createElement("div");
list.className = "rvp-list";
{
arr.forEach(function (e) {
var total = (Number(e.upb) || 0) * (Number(e.box) || 0);
var nm = splitName(nameOf(e));
var line = document.createElement("div");
line.className = "rvp-line";
var main = document.createElement("span");
main.className = "rvp-name";
main.textContent = nm.main;
line.appendChild(main);
if (nm.size) {
var size = document.createElement("span");
size.className = "rvp-size";
size.textContent = nm.size;
line.appendChild(size);
}
var calc = document.createElement("span");
calc.className = "rvp-calc";
calc.innerHTML = fmt(e.upb) + t("개", "ea") + " &times; "
+ "<b class=\"rvp-box\">" + fmt(e.box) + t("박스", "box") + "</b>"
+ " = " + fmt(total);
line.appendChild(calc);
list.appendChild(line);
});
}
sheet.appendChild(list);
pages.appendChild(sheet);
});
document.body.classList.add("printing-cells");
document.getElementById("rvp-overlay").classList.add("is-open");
}
function closePreview() {
document.getElementById("rvp-overlay").classList.remove("is-open");
document.body.classList.remove("printing-cells");
}
// ── 창고 POG: 면(Side A/B/Ground)마다 A4 가로 1장 미리보기 ──
function openPOG() {
var board = document.getElementById("rv-board");
var pages = document.getElementById("pog-pages");
if (!board || !pages) return;
var wh = board.getAttribute("data-wh") || "";
var st = board.getAttribute("data-stocktake") || "";
var metaTxt = [wh, st].filter(Boolean).join(" | ");
pages.innerHTML = "";
[].slice.call(board.querySelectorAll(".rv-side")).forEach(function (side) {
var sheet = document.createElement("div");
sheet.className = "pog-sheet";
// 머리말: 면 이름(Side A/B/Ground) + 창고/기준 재고조사
var head = document.createElement("div");
head.className = "pog-sheet-head";
var ttl = document.createElement("span");
ttl.className = "pog-t";
var h3 = side.querySelector("h3");
ttl.textContent = h3 ? h3.textContent.trim() : t("창고 POG", "Warehouse POG");
var meta = document.createElement("span");
meta.className = "pog-meta";
meta.textContent = metaTxt;
head.appendChild(ttl);
head.appendChild(meta);
sheet.appendChild(head);
// 면 복제 → 인터랙티브 요소 제거(체크박스/드래그)
var clone = side.cloneNode(true);
[].slice.call(clone.querySelectorAll(".rv-cell")).forEach(function (c) {
c.removeAttribute("draggable");
c.classList.remove("is-selected", "is-dragover", "dragging");
});
[].slice.call(clone.querySelectorAll(".rv-pick")).forEach(function (cb) {
cb.parentNode && cb.parentNode.removeChild(cb);
});
sheet.appendChild(clone);
pages.appendChild(sheet);
});
document.body.classList.add("printing-pog");
document.getElementById("pog-overlay").classList.add("is-open");
}
function closePOG() {
document.getElementById("pog-overlay").classList.remove("is-open");
document.body.classList.remove("printing-pog");
}
document.addEventListener("DOMContentLoaded", function () {
renderAll();
var board = document.getElementById("rv-board");
if (board) {
board.addEventListener("dragstart", onDragStart);
board.addEventListener("dragover", onDragOver);
board.addEventListener("dragleave", onDragLeave);
board.addEventListener("drop", onDrop);
board.addEventListener("dragend", cleanup);
board.addEventListener("change", onChange);
}
var sb = document.getElementById("rv-save-btn");
if (sb) sb.addEventListener("click", save);
var pb = document.getElementById("rv-print-btn");
if (pb) pb.addEventListener("click", openPreview);
var pc = document.getElementById("rvp-close");
if (pc) pc.addEventListener("click", closePreview);
var pp = document.getElementById("rvp-print");
if (pp) pp.addEventListener("click", function () { window.print(); });
var gb = document.getElementById("rv-pog-btn");
if (gb) gb.addEventListener("click", openPOG);
var gc = document.getElementById("pog-close");
if (gc) gc.addEventListener("click", closePOG);
var gp = document.getElementById("pog-print");
if (gp) gp.addEventListener("click", function () { window.print(); });
var lt = document.getElementById("mys-lang-toggle");
if (lt) lt.addEventListener("click", function () { setTimeout(renderAll, 0); });
window.addEventListener("beforeunload", function (ev) {
if (dirty) { ev.preventDefault(); ev.returnValue = ""; }
});
});
})();
+319
View File
@@ -0,0 +1,319 @@
/* 프로젝트 관리(아사나식) 모듈 스타일.
* 기존 erp.css 토큰 위에 아사나 느낌(밝은 캔버스, , 칸반 컬럼) 얹는다. */
.pj-wrap { padding: 4px 2px 40px; }
/* ── 버튼 ── */
.pj-btn {
border: 1px solid #d8dce2; background: #fff; color: #1e1f21;
padding: 7px 14px; border-radius: 8px; font-size: 13px; font-weight: 600;
cursor: pointer; transition: background .12s, border-color .12s;
}
.pj-btn:hover { background: #f6f7f8; }
.pj-btn-primary { background: #1e1f21; color: #fff; border-color: #1e1f21; }
.pj-btn-primary:hover { background: #000; }
.pj-btn-danger { background: #fff; color: #c22b10; border-color: #e6c4bd; }
.pj-btn-danger:hover { background: #fbecea; }
.pj-icon-btn {
border: none; background: transparent; color: #6b7280; cursor: pointer;
font-size: 16px; line-height: 1; width: 24px; height: 24px; border-radius: 6px;
}
.pj-icon-btn:hover { background: #eceef0; color: #1e1f21; }
.pj-section-head { display: flex; align-items: center; justify-content: space-between; margin: 8px 0 14px; }
.pj-section-head h2 { font-size: 16px; font-weight: 700; margin: 0; }
.pj-empty { padding: 28px; text-align: center; color: #6b7280; background: #f7f8f9; border-radius: 12px; }
.pj-empty-sm { padding: 10px 2px; color: #9aa1a9; font-size: 13px; }
.pj-chip { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #eef0f2; color: #525860; font-size: 11px; font-weight: 600; }
.pj-chip-sm { padding: 1px 6px; font-size: 10px; }
/* ── 홈 ── */
.pj-home { display: grid; grid-template-columns: 1fr 320px; gap: 28px; align-items: start; }
@media (max-width: 980px) { .pj-home { grid-template-columns: 1fr; } }
.pj-project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
.pj-project-card {
position: relative; border: 1px solid #e7e9ec; border-radius: 14px;
background: #fff; transition: box-shadow .14s, transform .1s;
}
.pj-project-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); transform: translateY(-1px); }
.pj-project-cardlink { display: flex; gap: 12px; padding: 16px; text-decoration: none; color: inherit; }
/* 완료 프로젝트 — 비활성(흐리게) */
.pj-project-card.is-done { background: #f6f7f8; opacity: .62; }
.pj-project-card.is-done:hover { opacity: .85; }
.pj-project-card.is-done .pj-project-dot { background: #9aa5b1; }
/* 완료 숨기기 토글 */
.pj-project-grid.pj-hide-done .pj-project-card.is-done { display: none; }
/* 카드 수정 버튼 */
.pj-card-edit {
position: absolute; top: 8px; right: 8px; width: 28px; height: 28px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px; color: #6b7280; opacity: 0; transition: opacity .12s;
}
.pj-card-edit .material-symbols-outlined { font-size: 18px; }
.pj-project-card:hover .pj-card-edit { opacity: 1; }
.pj-card-edit:hover { background: #eceef0; color: #1e1f21; }
.pj-project-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pj-color, #4573d2); margin-top: 5px; flex: 0 0 auto; }
.pj-project-name { font-weight: 700; font-size: 15px; }
.pj-project-desc { color: #6b7280; font-size: 12.5px; margin-top: 3px; }
.pj-project-meta { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; }
.pj-home-side { border-left: 1px solid #eef0f2; padding-left: 24px; }
@media (max-width: 980px) { .pj-home-side { border-left: none; padding-left: 0; } }
.pj-mytask-list { list-style: none; margin: 0; padding: 0; }
.pj-mytask a { display: flex; align-items: center; gap: 8px; padding: 9px 6px; border-radius: 8px; text-decoration: none; color: inherit; }
.pj-mytask a:hover { background: #f6f7f8; }
.pj-mytask.is-done .pj-mytask-title { text-decoration: line-through; color: #9aa1a9; }
.pj-mytask-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.pj-mytask-title { font-size: 13.5px; font-weight: 600; }
.pj-mytask-meta { margin-left: auto; font-size: 11px; color: #9aa1a9; }
/* ── 프로젝트 페이지 레이아웃 ── */
.pj-layout { display: grid; grid-template-columns: 230px 1fr; gap: 22px; align-items: start; }
@media (max-width: 900px) { .pj-layout { grid-template-columns: 1fr; } }
.pj-side-block { margin-bottom: 22px; }
.pj-side-head { display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 8px; }
.pj-member-list { list-style: none; margin: 0; padding: 0; }
.pj-member-list li { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-radius: 7px; }
.pj-member-list li:hover { background: #f6f7f8; }
.pj-member-name { font-size: 13px; flex: 1; }
/* ── 아사나식 프로젝트 트리 ── */
.pj-tree { list-style: none; margin: 0; padding: 0; }
.pj-tree .pj-tree { padding-left: 0; } /* 들여쓰기는 row 패딩으로 처리 */
.pj-tree-row { display: flex; align-items: center; gap: 2px; border-radius: 7px; padding-right: 2px;
padding-left: calc(var(--pj-indent, 0) * 16px); position: relative; }
.pj-tree-row:hover { background: #f1f2f4; }
.pj-tree-row.is-active { background: #ececf8; }
.pj-tree-row.is-active .pj-tree-name { font-weight: 700; color: #1e1f21; }
.pj-tree-caret { border: none; background: transparent; cursor: pointer; width: 20px; height: 26px;
display: inline-flex; align-items: center; justify-content: center; color: #6b7280; padding: 0; }
.pj-tree-caret .material-symbols-outlined { font-size: 18px; transition: transform .12s; }
.pj-tree-caret.is-open .material-symbols-outlined { transform: rotate(90deg); }
.pj-tree-caret-spacer { width: 20px; flex: 0 0 auto; }
.pj-tree-link { display: flex; align-items: center; gap: 7px; flex: 1; min-width: 0;
text-decoration: none; color: inherit; padding: 5px 2px; }
.pj-tree-icon { font-size: 18px; flex: 0 0 auto; }
.pj-tree-name { font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pj-tree-acts { display: none; gap: 0; flex: 0 0 auto; }
.pj-tree-row:hover .pj-tree-acts { display: inline-flex; }
.pj-tree-acts .pj-icon-btn { width: 22px; height: 22px; font-size: 14px; }
.pj-tree-done { text-decoration: line-through; color: #9aa1a9; }
.pj-tree-task .pj-tree-name { font-size: 13px; color: #444; }
/* 구글 머티리얼 심볼 아이콘(담당자) */
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; line-height: 1; vertical-align: middle; }
.pj-gicon { font-size: 24px; color: #5f6368; flex: 0 0 auto; }
.pj-gicon-sm { font-size: 20px; }
.pj-assignee { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: #5f6368; }
/* 구글 프로필 이미지 — 원형 */
.pj-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; vertical-align: middle; background: #e7e9ec; }
.pj-avatar.pj-gicon-sm { width: 18px; height: 18px; }
/* ── 툴바 + 탭 ── */
.pj-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.pj-view-tabs { display: inline-flex; background: #eef0f2; border-radius: 10px; padding: 3px; gap: 2px; }
.pj-tab { border: none; background: transparent; padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #525860; cursor: pointer; }
.pj-tab.is-active { background: #1e1f21; color: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.18); }
/* 알림 벨 흔들림(미읽음 있을 때) */
@keyframes pj-bell-ring {
0%, 50%, 100% { transform: rotate(0); }
10%, 30% { transform: rotate(14deg); }
20%, 40% { transform: rotate(-14deg); }
}
.pj-bell.has-unread .material-symbols-outlined {
animation: pj-bell-ring 1.4s ease-in-out infinite;
transform-origin: top center;
}
/* ── 보드(칸반) ── */
.pj-board { display: flex; gap: 14px; overflow-x: auto; padding-bottom: 8px; align-items: flex-start; }
.pj-col { flex: 0 0 272px; background: #f7f8f9; border-radius: 12px; padding: 10px; }
.pj-col-head { display: flex; align-items: center; gap: 6px; font-weight: 700; font-size: 13px; padding: 4px 6px 10px; }
.pj-col-count { margin-left: auto; color: #9aa1a9; font-weight: 600; }
.pj-col-body { display: flex; flex-direction: column; gap: 8px; min-height: 40px; border-radius: 8px; }
.pj-col-body.is-over { background: #e6ecfa; outline: 2px dashed #aebfe6; }
.pj-card { background: #fff; border: 1px solid #e7e9ec; border-radius: 10px; padding: 10px 12px; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.pj-card:hover { border-color: #c9cdd3; }
.pj-card.is-dragging { opacity: .5; }
.pj-card.is-done .pj-card-title { text-decoration: line-through; color: #9aa1a9; }
.pj-card-title { font-size: 13.5px; font-weight: 600; }
.pj-card-meta { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
.pj-card-due { margin-left: auto; font-size: 11px; color: #9aa1a9; }
.pj-pri { padding: 1px 7px; border-radius: 999px; font-size: 10px; font-weight: 700; }
.pj-pri-high { background: #fbe3df; color: #c22b10; }
.pj-pri-low { background: #e6eef0; color: #4a6066; }
/* ── 리스트 ── */
.pj-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.pj-table th { text-align: left; padding: 9px 10px; border-bottom: 2px solid #eef0f2; color: #1e1f21; font-size: 14px; font-weight: 700; position: relative; }
.pj-table td { padding: 10px; border-bottom: 1px solid #f1f2f4; }
.pj-table tr.is-done td { color: #9aa1a9; text-decoration: line-through; }
.pj-table-fixed { table-layout: fixed; }
.pj-table-fixed th, .pj-table-fixed td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pj-th-label { cursor: pointer; user-select: none; display: inline-flex; align-items: center; gap: 4px; }
.pj-th-label:hover { color: #4573d2; }
.pj-th-sort { font-size: 11px; color: #9aa1a9; }
.pj-th-resize { position: absolute; top: 0; right: -3px; width: 8px; height: 100%; cursor: col-resize; z-index: 2; }
.pj-th-resize:hover { background: linear-gradient(90deg, transparent 40%, #c9d4ea 40%, #c9d4ea 60%, transparent 60%); }
/* ── 내 업무 트리(홈) ── */
.pj-mytree-group { margin-bottom: 14px; }
.pj-mytree-proj { display: flex; align-items: center; gap: 7px; text-decoration: none; color: #1e1f21; font-weight: 700; font-size: 13.5px; padding: 4px 2px; border-radius: 7px; }
.pj-mytree-proj:hover { background: #f6f7f8; }
.pj-mytree-tasks { list-style: none; margin: 2px 0 0; padding: 0 0 0 8px; border-left: 2px solid #eef0f2; margin-left: 10px; }
.pj-mytree-task a { display: flex; align-items: flex-start; gap: 7px; padding: 6px 6px; border-radius: 7px; text-decoration: none; color: inherit; }
.pj-mytree-task a:hover { background: #f6f7f8; }
.pj-mytree-ic { font-size: 16px; flex: 0 0 auto; margin-top: 1px; }
.pj-mytree-body { display: flex; flex-direction: column; min-width: 0; }
.pj-mytree-title { font-size: 13px; font-weight: 600; }
.pj-mytree-dates { font-size: 11px; color: #9aa1a9; margin-top: 1px; }
.pj-mytree-task.is-done .pj-mytree-title { text-decoration: line-through; color: #9aa1a9; }
/* ── 달력 (휴가 모듈과 동일한 월간 그리드 룩) ── */
.pj-cal-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.pj-cal-title { font-size: 18pt; font-weight: 600; margin: 0; letter-spacing: -0.45px; }
.pj-nav-btn { padding: 2px 12px; font-size: 18pt; line-height: 1; }
.pj-today-btn { margin-left: auto; }
.pj-cal { border: 1px solid #e5e5e5; border-radius: 10px; overflow: hidden; display: flex; flex-direction: column; }
.pj-wd-row { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); background: #f2f2f2; border-bottom: 1px solid #e5e5e5; }
.pj-wd { text-align: center; padding: 8px 0; font-size: 15px; font-weight: 600; color: #0a0a0a; border-right: 1px solid #e5e5e5; }
.pj-wd:last-child { border-right: none; }
/* 주(week) — 날짜 셀 위에 bar 레이어 오버레이. 6주가 높이 균등 분할. */
.pj-week { position: relative; border-bottom: 1px solid #e5e5e5; flex: 1 1 0; min-height: 134px; display: flex; flex-direction: column; }
.pj-week:last-child { border-bottom: none; }
.pj-week-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); flex: 1 1 auto; min-height: 0; }
.pj-day { height: 100%; border-right: 1px solid #e5e5e5; padding: 6px 8px; box-sizing: border-box; }
.pj-day:last-child { border-right: none; }
.pj-day-num { font-size: 20px; font-weight: 600; color: #0a0a0a; }
.pj-out { background: #fafafa; }
.pj-out .pj-day-num { color: #bbb; }
.pj-today { background: #f5f5f5; }
.pj-today .pj-day-num { background: #0a0a0a; color: #fff !important; border-radius: 9999px; padding: 2px 9px; }
.pj-today .pj-day-num.pj-red { background: #c22b10; }
.pj-today .pj-day-num.pj-blue { background: #1d4ed8; }
.pj-red { color: #c22b10 !important; }
.pj-blue { color: #1d4ed8 !important; }
/* bar 오버레이 — 날짜 숫자 아래(top)부터 7열 그리드로 겹쳐 그림 */
.pj-week-bars { position: absolute; left: 0; right: 0; top: 34px; bottom: 4px; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); grid-auto-rows: 22px; row-gap: 2px; pointer-events: none; }
.pj-bar { margin: 0 3px; padding: 0 7px; height: 20px; line-height: 20px; border-radius: 5px; font-size: 12.5px; font-weight: 500; color: #fff; text-decoration: none; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; align-self: center; pointer-events: auto; cursor: pointer; }
.pj-bar:hover { filter: brightness(.93); }
.pj-bar-label { pointer-events: none; }
/* "+N 더" 칩 — 넘친 업무. 클릭 시 그날 목록 팝오버 */
.pj-bar-more { margin: 0 3px; height: 18px; line-height: 18px; align-self: center; font-size: 11.5px; font-weight: 600; color: #5a6772; cursor: pointer; pointer-events: auto; padding: 0 4px; border-radius: 5px; }
.pj-bar-more:hover { background: #eceef0; color: #1e1f21; }
/* 그날 업무 팝오버 */
.pj-daypop { position: absolute; z-index: 2100; min-width: 200px; max-width: 280px; background: #fff; border: 1px solid #e2e5e9; border-radius: 12px; box-shadow: 0 8px 28px rgba(0,0,0,.16); padding: 8px; }
.pj-daypop-head { font-size: 12px; font-weight: 700; color: #525860; padding: 4px 6px 8px; }
.pj-daypop-item { display: flex; align-items: center; gap: 8px; padding: 6px 6px; border-radius: 8px; cursor: pointer; font-size: 13px; }
.pj-daypop-item:hover { background: #f2f4f6; }
.pj-daypop-item.is-done .pj-daypop-title { text-decoration: line-through; color: #9aa1a9; }
.pj-daypop-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }
.pj-daypop-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pj-bar-l { border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: 0; }
.pj-bar-r { border-top-right-radius: 0; border-bottom-right-radius: 0; margin-right: 0; }
.pj-bar-done { opacity: .75; text-decoration: line-through; }
/* 드래그 이동(포인터 기반) — 드래그 중엔 bar 가 이벤트 가로채지 않게(elementFromPoint 가 날짜 셀 반환) */
.pj-cal-dragging { cursor: grabbing; }
.pj-cal-dragging .pj-bar { pointer-events: none; }
.pj-project-view[data-can-manage="true"] .pj-bar { cursor: grab; }
.pj-day.pj-drop-over { background: #e6ecfa; box-shadow: inset 0 0 0 2px #aebfe6; }
/* 드래그 중 따라다니는 고스트 라벨 */
.pj-bar-ghost { position: fixed; z-index: 2000; pointer-events: none; padding: 2px 10px; border-radius: 6px; background: #1e1f21; color: #fff; font-size: 12px; box-shadow: 0 4px 14px rgba(0,0,0,.3); white-space: nowrap; }
/* ── 타임라인 컨테이너 ── */
#pj-timeline { background: #fff; border-radius: 12px; padding: 6px; }
/* ── 모달 ── */
.pj-modal { position: fixed; inset: 0; background: rgba(20,22,26,.46); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.pj-modal[hidden] { display: none; }
.pj-modal-card { background: #fff; border-radius: 16px; padding: 22px; width: min(480px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.25); }
.pj-modal-card h3 { margin: 0 0 16px; font-size: 17px; }
.pj-modal-hint { margin: -6px 0 14px; font-size: 12px; color: #6b7280; }
.pj-modal-card label { display: block; font-size: 12.5px; font-weight: 600; color: #525860; margin-bottom: 12px; }
.pj-modal-card input[type=text], .pj-modal-card input[type=date], .pj-modal-card textarea, .pj-modal-card select {
display: block; width: 100%; margin-top: 5px; padding: 8px 10px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13.5px; font-family: inherit; box-sizing: border-box;
}
.pj-form-row { display: flex; gap: 12px; }
.pj-form-row label { flex: 1; }
.pj-time-toggle { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; color: #525860; margin-bottom: 12px; cursor: pointer; }
.pj-time-toggle input { width: auto; margin: 0; }
.pj-form-row input[type="time"] { display: block; width: 100%; margin-top: 5px; padding: 8px 10px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13.5px; font-family: inherit; box-sizing: border-box; }
.pj-form-row input[type="time"]:disabled { background: #f2f2f2; color: #aaa; cursor: not-allowed; }
#pj-time-row:has(input:disabled) label { color: #aaa; }
.pj-modal-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.pj-modal-actions .pj-btn:last-child { }
.pj-color-palette, #pj-f-colors { display: flex; gap: 8px; margin-top: 6px; }
.pj-color-swatch { width: 24px; height: 24px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; }
.pj-color-swatch.is-active { border-color: #1e1f21; box-shadow: 0 0 0 2px #fff inset; }
/* ── 알림 벨 ── */
.pj-toolbar-right { display: flex; align-items: center; gap: 12px; }
.pj-bell { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: 10px; color: #525860; text-decoration: none; }
.pj-bell:hover { background: #eceef0; }
.pj-bell-badge { position: absolute; top: 3px; right: 3px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: #e8384f; color: #fff; font-size: 10px; font-weight: 700; line-height: 16px; text-align: center; }
/* ── 업무 모달 댓글/첨부 ── */
.pj-task-extra { border-top: 1px solid #eef0f2; margin-top: 14px; padding-top: 12px; }
.pj-extra-head { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700; color: #525860; margin-bottom: 8px; }
.pj-upload-btn { margin-left: auto; font-size: 12px; font-weight: 600; color: #4573d2; cursor: pointer; padding: 3px 8px; border: 1px solid #cdd9f0; border-radius: 7px; }
.pj-upload-btn:hover { background: #eef3fc; }
.pj-attach-list, .pj-comment-list { list-style: none; margin: 0 0 6px; padding: 0; max-height: 160px; overflow-y: auto; }
.pj-attach-item { display: flex; align-items: center; gap: 8px; padding: 5px 4px; font-size: 13px; }
.pj-attach-item a { color: #2b4a85; text-decoration: none; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pj-attach-item a:hover { text-decoration: underline; }
.pj-attach-size { font-size: 11px; color: #9aa1a9; }
.pj-comment-item { display: flex; align-items: flex-start; gap: 8px; padding: 7px 2px; }
.pj-comment-main { flex: 1; }
.pj-comment-head { font-size: 12.5px; font-weight: 700; color: #1e1f21; }
.pj-comment-time { font-weight: 400; color: #9aa1a9; font-size: 11px; margin-left: 6px; }
.pj-comment-body { font-size: 13px; color: #333; margin-top: 1px; white-space: pre-wrap; }
.pj-comment-form { display: flex; gap: 6px; }
.pj-comment-form input { flex: 1; padding: 7px 10px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13px; font-family: inherit; }
.pj-comment-acts { display: inline-flex; gap: 1px; flex: 0 0 auto; }
.pj-comment-edit-box { margin-top: 5px; }
.pj-comment-edit-box textarea { width: 100%; box-sizing: border-box; padding: 7px 9px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13px; font-family: inherit; resize: vertical; }
.pj-comment-edit-actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 6px; }
.pj-comment-edit-actions .pj-btn { padding: 4px 12px; font-size: 12px; }
/* 보드 카드 — 전체 뷰에서 어느 프로젝트인지 배지 표시 */
.pj-card-proj { font-size: 11px; font-weight: 700; margin-bottom: 3px; letter-spacing: -.2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 댓글 말풍선(💬 이모지) 표시 */
.pj-cmt-ic { font-size: 12px; vertical-align: middle; margin-left: 4px; line-height: 1; }
.pj-cmt-n { font-size: 11px; font-weight: 700; vertical-align: middle; margin-left: 1px; }
.pj-card-cmt { display: inline-flex; align-items: center; color: #6b7280; }
/* ── 보드 단계 컨트롤 ── */
.pj-col-head { position: relative; }
.pj-col-name { font-weight: 700; }
.pj-col-ctrls { display: none; gap: 1px; margin-left: 6px; }
.pj-col:hover .pj-col-ctrls { display: inline-flex; }
.pj-col-ctrls .pj-icon-btn { width: 20px; height: 20px; font-size: 13px; }
.pj-icon-btn[disabled] { opacity: .3; cursor: default; }
.pj-col-add { background: transparent; flex: 0 0 180px; }
.pj-add-stage { width: 100%; padding: 10px; border: 1px dashed #c9cdd3; border-radius: 12px; background: #fff; color: #6b7280; font-size: 13px; font-weight: 600; cursor: pointer; }
.pj-add-stage:hover { border-color: #1e1f21; color: #1e1f21; }
/* ── 알림센터 ── */
.pj-inbox { list-style: none; margin: 0; padding: 0; }
.pj-inbox-item { display: flex; align-items: flex-start; gap: 12px; padding: 14px 12px; border: 1px solid #eef0f2; border-radius: 12px; margin-bottom: 8px; cursor: pointer; background: #fff; }
.pj-inbox-item:hover { border-color: #d8dce2; }
.pj-inbox-item.is-unread { background: #f4f8ff; border-color: #d6e2fb; }
.pj-inbox-icon { color: #4573d2; font-size: 22px; flex: 0 0 auto; }
.pj-inbox-body { flex: 1; }
.pj-inbox-title { font-size: 14px; font-weight: 600; }
.pj-inbox-item.is-unread .pj-inbox-title::before { content: "●"; color: #4573d2; font-size: 9px; vertical-align: middle; margin-right: 6px; }
.pj-inbox-sub { font-size: 12.5px; color: #525860; margin-top: 2px; }
.pj-inbox-meta { font-size: 11px; color: #9aa1a9; margin-top: 4px; }
.pj-inbox-go { align-self: center; padding: 4px 12px; font-size: 12px; }
File diff suppressed because it is too large Load Diff
+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();
})();
+26
View File
@@ -0,0 +1,26 @@
/* Material Symbols Outlined self-host (구글 CDN 대체).
원본: https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined
폰트 파일은 같은 폴더 material-symbols-outlined.woff2. */
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 400;
src: url(material-symbols-outlined.woff2) format('woff2');
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+78 -9
View File
@@ -10,19 +10,37 @@ import json
import os
import tempfile
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from .timezone import now_kst_iso
# 슈퍼 관리자 — 강등/삭제 불가
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",
"malaysia",
"dispatch",
"project",
"expense_approver",
"vacation_approver",
)
# 승인자 권한 키 — 결재 워크플로에서 별도 검사할 때 사용
APPROVER_KEYS: tuple[str, ...] = ("expense_approver", "vacation_approver")
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
return now_kst_iso()
class UserStore:
@@ -64,14 +82,14 @@ class UserStore:
@staticmethod
def _normalize_modules(modules: dict[str, Any] | None, *, is_admin: bool) -> dict[str, bool]:
# 신규(modules 미지정) 사용자는 역할 기본값(admin=전체ON / user=전체OFF)으로 시작.
# 기존 값이 있으면 그대로 존중한다 — admin 이라도 슈퍼관리자가 개별 모듈을
# 끌 수 있다(예전엔 admin 을 무조건 전체 ON 으로 덮어썼다).
base = UserStore._default_modules(is_admin)
if isinstance(modules, dict):
for key in MODULE_KEYS:
if key in modules:
base[key] = bool(modules[key])
if is_admin:
for key in MODULE_KEYS:
base[key] = True
return base
def get(self, email: str) -> dict[str, Any] | None:
@@ -88,6 +106,42 @@ class UserStore:
data = self._read()
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]:
"""로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신."""
email = email.lower().strip()
@@ -176,10 +230,25 @@ def is_admin(user_rec: dict[str, Any] | None) -> bool:
def has_module(user_rec: dict[str, Any] | None, module: str) -> bool:
if not user_rec:
return False
if is_admin(user_rec):
# 슈퍼관리자는 항상 전체 접근. 일반 admin 은 칩(modules) 상태를 따른다 —
# 슈퍼관리자가 admin 의 개별 프로그램 접근을 제어할 수 있도록.
if user_rec.get("is_super_admin"):
return True
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]:
+172 -70
View File
@@ -5,21 +5,63 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>사용자/권한 관리 — DBX ERP</title>
<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-group { display: inline-block; margin-left: 6px; padding: 2px 6px; border-radius: 6px;
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
font-size: 10px; font-weight: 500; vertical-align: middle; }
/* 권한 칩 — 모듈 수가 늘어도 줄바꿈되어 가로로 안 넓어짐 */
.erp-perm-cell { padding: 8px 6px; }
.erp-perm-chip {
display: inline-flex; align-items: center; gap: 4px;
margin: 3px; padding: 4px 11px; border-radius: 999px;
border: 1px solid var(--color-subtle-ash); background: #fff;
color: var(--color-midtone-gray); font-size: 12px; font-weight: 500;
font-family: inherit; cursor: pointer; user-select: none;
transition: background .1s, color .1s, border-color .1s;
}
.erp-perm-chip:hover { border-color: var(--color-midtone-gray); }
.erp-perm-chip.is-on {
background: var(--color-rich-black); color: #fff; border-color: var(--color-rich-black);
}
/* 승인 권한 칩 — 켜지면 파랑 강조로 구분 */
.erp-perm-chip.is-approver.is-on {
background: #1d4ed8; border-color: #1d4ed8;
}
.erp-perm-chip[data-locked="true"] { opacity: .5; cursor: not-allowed; }
.erp-perm-chip[data-locked="true"]:hover { border-color: var(--color-subtle-ash); }
</style>
</head>
<body class="erp-body">
<div class="erp-shell">
<!-- ── 상단 네비게이션 ── -->
<nav class="erp-nav">
<div class="erp-nav-left">
<a href="/" class="erp-brand">
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
<span class="erp-brand-divider"></span>
<span class="erp-brand-system">ERP 시스템</span>
<span class="erp-brand-system">DBX ERP System</span>
</a>
</div>
<div class="erp-nav-right">
<a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a>
<div class="erp-user-chip">
@@ -37,39 +79,40 @@
</div>
</nav>
<main class="erp-main" style="max-width: 1100px;">
<main class="erp-main" style="max-width: 1280px;">
<h1 class="erp-greeting">사용자 / 권한 관리</h1>
<p class="erp-greeting-sub">
회사 계정으로 로그인한 임직원의 모듈 접근 권한을 관리합니다.
토글을 변경한 뒤 <strong>저장</strong> 버튼을 누르세요.
이메일만으로 사용자 등록 가능 (회사 도메인 자동 검사).
토글을 변경한 뒤 <strong>저장</strong>을 누르세요.
</p>
<!-- 이메일 등록 폼 + 검색 -->
<div class="erp-admin-toolbar">
<input type="search"
id="user-search"
class="erp-input"
placeholder="이메일 또는 이름으로 검색…" />
<input type="search" id="user-search" class="erp-input"
placeholder="이메일/이름 검색…" style="padding: 6px 10px;" />
<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 class="erp-table-wrap">
<table class="erp-table" id="users-table">
<thead>
<tr>
<th style="width: 28%;">사용자</th>
<th style="width: 11%;">역할</th>
{% for key in module_keys %}
<th style="text-align:center; width: 9%;">
{% if key == 'corm' %}CORM
{% elif key == 'order' %}Order
{% elif key == 'expense' %}개인경비
{% elif key == 'vacation' %}휴가
{% else %}{{ key }}{% endif %}
</th>
{% endfor %}
<th style="width: 13%;">최근 로그인</th>
<th style="width: 12%; text-align: right;">동작</th>
<th style="width: 200px;">사용자</th>
<th style="width: 80px;">역할</th>
<th>접근 권한 <span class="erp-mod-group">클릭하여 토글</span></th>
<th style="width: 110px;">최근 로그인</th>
<th style="width: 130px; text-align: right;">동작</th>
</tr>
</thead>
<tbody>
@@ -101,24 +144,29 @@
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
</select>
</td>
{% for key in module_keys %}
<td style="text-align:center;">
<label class="erp-switch">
<input type="checkbox"
data-field="module"
data-module="{{ key }}"
{% if u.modules[key] %}checked{% endif %}
{% if u.is_super_admin or u.role == 'admin' %}disabled{% endif %} />
<span class="erp-switch-slider"></span>
</label>
<td class="erp-perm-cell">
{% for key in module_keys %}
<button type="button"
class="erp-perm-chip
{% if u.modules[key] %}is-on{% endif %}
{% if key in approver_keys %}is-approver{% endif %}"
data-module="{{ key }}"
aria-pressed="{{ 'true' if u.modules[key] else 'false' }}"
{% if u.is_super_admin %}data-locked="true"{% endif %}>
{{ module_labels.get(key, key) }}{% if key in approver_keys %} 승인{% endif %}
</button>
{% endfor %}
</td>
{% endfor %}
<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 style="text-align: right;">
<button class="erp-btn erp-btn-primary" data-action="save"
{% 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>
</tr>
{% endfor %}
@@ -127,8 +175,9 @@
</div>
<p class="erp-section-meta" style="margin-top: var(--sp-16);">
<strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자이므로 권한 변경할 수 없습니다.
관리자(admin) 역할로 지정된 사용자에게는 모든 모듈 권한이 자동 부여됩니다.
<strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자, 권한 변경/삭제 불가(항상 전체 접근).
admin 역할은 기본 전체 ON 이지만 슈퍼관리자가 개별 프로그램 접근을 끌 수 있다.
<code>expense_approver</code> / <code>vacation_approver</code> 는 결재 승인 권한.
</p>
</main>
@@ -141,6 +190,7 @@
const searchInput = document.getElementById("user-search");
const userCountEl = document.getElementById("user-count");
const toastEl = document.getElementById("toast");
const addForm = document.getElementById("add-user-form");
function markDirty(tr) { tr.setAttribute("data-dirty", "true"); }
function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); }
@@ -150,22 +200,25 @@
toastEl.dataset.type = type || "info";
toastEl.dataset.visible = "true";
clearTimeout(showToast._t);
showToast._t = setTimeout(() => {
toastEl.dataset.visible = "false";
}, 2400);
showToast._t = setTimeout(() => { toastEl.dataset.visible = "false"; }, 2400);
}
// 역할 변경시 모듈 토글 활성/비활성 갱신
function syncModuleSwitches(tr) {
function syncPermChips(tr) {
// 슈퍼관리자만 전체 ON + 잠금. 일반 admin 은 슈퍼관리자가 개별 제어 가능.
// 역할을 admin 으로 바꾸면 편의상 전체 ON(잠금 아님 — 다시 끌 수 있음).
const isSuper = tr.dataset.super === "true";
const role = tr.querySelector("select[data-field='role']").value;
const adminRole = role === "admin";
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
if (isSuper || adminRole) {
cb.checked = true;
cb.disabled = true;
const adminRole = tr.querySelector("select[data-field='role']").value === "admin";
tr.querySelectorAll(".erp-perm-chip").forEach((chip) => {
if (isSuper) {
chip.classList.add("is-on");
chip.setAttribute("aria-pressed", "true");
chip.setAttribute("data-locked", "true");
} else {
cb.disabled = false;
chip.removeAttribute("data-locked");
if (adminRole) {
chip.classList.add("is-on");
chip.setAttribute("aria-pressed", "true");
}
}
});
}
@@ -173,47 +226,96 @@
tbody.addEventListener("change", (e) => {
const tr = e.target.closest("tr");
if (!tr) return;
if (e.target.dataset.field === "role") {
syncModuleSwitches(tr);
}
if (e.target.dataset.field === "role") syncPermChips(tr);
markDirty(tr);
});
tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button[data-action='save']");
// 권한 칩 토글 (잠금 상태 제외)
const chip = e.target.closest(".erp-perm-chip");
if (chip) {
if (chip.getAttribute("data-locked") === "true") return;
const on = chip.classList.toggle("is-on");
chip.setAttribute("aria-pressed", on ? "true" : "false");
markDirty(chip.closest("tr"));
return;
}
const btn = e.target.closest("button[data-action]");
if (!btn) return;
const tr = btn.closest("tr");
const email = tr.dataset.email;
const role = tr.querySelector("select[data-field='role']").value;
const modules = {};
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
modules[cb.dataset.module] = cb.checked;
});
btn.disabled = true;
btn.textContent = "저장 중…";
if (btn.dataset.action === "save") {
const role = tr.querySelector("select[data-field='role']").value;
const modules = {};
tr.querySelectorAll(".erp-perm-chip").forEach((c) => {
modules[c.dataset.module] = c.classList.contains("is-on");
});
btn.disabled = true; btn.textContent = "저장 중…";
try {
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ role, modules }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || `HTTP ${res.status}`);
}
clearDirty(tr);
showToast(`${email} 저장 완료`, "info");
} catch (err) {
showToast(`저장 실패: ${err.message}`, "error");
} finally {
btn.disabled = false; 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/${encodeURIComponent(email)}`, {
method: "PUT",
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ role, modules }),
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || `HTTP ${res.status}`);
}
clearDirty(tr);
showToast(`${email} 저장 완료`, "info");
showToast(`${payload.email} 등록 완료. 새로고침합니다.`, "info");
setTimeout(() => location.reload(), 800);
} catch (err) {
showToast(`저장 실패: ${err.message}`, "error");
} finally {
btn.disabled = false;
btn.textContent = "저장";
showToast(`등록 실패: ${err.message}`, "error");
}
});
// 검색
searchInput.addEventListener("input", () => {
const q = searchInput.value.toLowerCase().trim();
let visible = 0;
+6 -3
View File
@@ -4,8 +4,9 @@
<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" />
<link rel="stylesheet" href="/static/erp-shell.css" />
<link rel="stylesheet" href="/static/erp.css?v=20260615b" />
<link rel="stylesheet" href="/static/erp-shell.css?v=20260615b" />
<link rel="stylesheet" href="/static/erp-attach-viewer.css" />
{% block head_extra %}{% endblock %}
</head>
<body class="erp-body erp-app-body">
@@ -18,7 +19,7 @@
<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">ERP</span>
<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"
@@ -114,6 +115,7 @@
</div>
<div class="erp-topbar-right">
{% block topbar_actions %}{% endblock %}
<div class="erp-user-chip">
{% if user.picture %}
<img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
@@ -159,6 +161,7 @@
})();
</script>
<script src="/static/erp-attach-viewer.js" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>
+1 -1
View File
@@ -16,7 +16,7 @@
<a href="/" class="erp-brand">
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
<span class="erp-brand-divider"></span>
<span class="erp-brand-system">ERP 시스템</span>
<span class="erp-brand-system">DBX ERP System</span>
</a>
</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)

Some files were not shown because too many files have changed in this diff Show More