Compare commits

146 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
77 changed files with 13545 additions and 45 deletions
+32
View File
@@ -32,6 +32,38 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
# 권한키: vacation(접근) / vacation_approver(승인·반려). admin 은 항상 통과. # 권한키: vacation(접근) / vacation_approver(승인·반려). admin 은 항상 통과.
# VACATION_DB_URL=postgresql://vacation_app:replace-me@postgres-db:5432/vacation_db # 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 읽기 전용) ─── # ─── 상품 검색 (itemcode_db 읽기 전용) ───
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만). # cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
# 미설정 시 검색 비활성 → 수동 등록만 가능. # 미설정 시 검색 비활성 → 수동 등록만 가능.
+3
View File
@@ -20,3 +20,6 @@ __pycache__/
.idea/ .idea/
.claude/ .claude/
# dispatch 라벨 PDF 샘플(실제 고객 PII — 커밋 금지)
docs/samples/
+3
View File
@@ -33,6 +33,9 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
- 개인경비 (`app/modules/expense/`, `expense_db`) - 개인경비 (`app/modules/expense/`, `expense_db`)
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용 - 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver` - 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia`
- 말레이시아 배송 (`app/modules/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`. 상세는 `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
+71
View File
@@ -15,8 +15,14 @@ from starlette.middleware.sessions import SessionMiddleware
from .modules.cupang import build_cupang_store, build_itemcode_reader from .modules.cupang import build_cupang_store, build_itemcode_reader
from .modules.cupang import router as cupang_router from .modules.cupang import router as cupang_router
from .modules.dispatch import build_dispatch_store
from .modules.dispatch import router as dispatch_router
from .modules.expense import CategoryStore, build_expense_store from .modules.expense import CategoryStore, build_expense_store
from .modules.expense import router as expense_router 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 build_vacation_store
from .modules.vacation import router as vacation_router from .modules.vacation import router as vacation_router
from .store import ( from .store import (
@@ -36,6 +42,9 @@ MODULE_LABELS: dict[str, str] = {
"expense": "개인경비", "expense": "개인경비",
"vacation": "휴가", "vacation": "휴가",
"cupang": "쿠팡 밀크런", "cupang": "쿠팡 밀크런",
"malaysia": "말레이시아 재고관리",
"dispatch": "말레이시아 배송",
"project": "프로젝트 관리",
"expense_approver": "개인경비", "expense_approver": "개인경비",
"vacation_approver": "휴가", "vacation_approver": "휴가",
} }
@@ -108,6 +117,9 @@ _MODULE_TEMPLATE_DIRS = [
BASE_DIR / "modules" / "expense" / "templates", BASE_DIR / "modules" / "expense" / "templates",
BASE_DIR / "modules" / "cupang" / "templates", BASE_DIR / "modules" / "cupang" / "templates",
BASE_DIR / "modules" / "vacation" / "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 = Jinja2Templates(directory=str(BASE_DIR / "templates"))
templates.env.loader = ChoiceLoader( templates.env.loader = ChoiceLoader(
@@ -117,6 +129,18 @@ 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() oauth = build_google_oauth()
user_store = UserStore(DATA_DIR / "users.json") user_store = UserStore(DATA_DIR / "users.json")
@@ -135,11 +159,25 @@ app.state.cupang_store = build_cupang_store(dsn=env("CUPANG_DB_URL") or None)
app.state.itemcode_reader = build_itemcode_reader() app.state.itemcode_reader = build_itemcode_reader()
# 휴가 관리: VACATION_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내). # 휴가 관리: VACATION_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
app.state.vacation_store = build_vacation_store(dsn=env("VACATION_DB_URL") or 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(expense_router)
app.include_router(cupang_router) app.include_router(cupang_router)
app.include_router(vacation_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: def public_url_for(request: Request, route_name: str) -> str:
@@ -282,6 +320,36 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
"status": "ready", "status": "ready",
"category": "관리", "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": "관리",
},
] ]
allowed = allowed_modules(user_rec) allowed = allowed_modules(user_rec)
for item in items: for item in items:
@@ -298,6 +366,9 @@ def _icon_svg(name: str) -> str:
"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"/>', "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"/>', "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"/>', "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"/>', "modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
} }
body = paths.get(name, paths["modules"]) body = paths.get(name, paths["modules"])
+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()
+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 %}
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

+9 -2
View File
@@ -285,16 +285,23 @@ body.erp-app-body { background: var(--color-canvas-white); height: 100vh; overfl
/* ── 반응형 ── */ /* ── 반응형 ── */
@media (max-width: 900px) { @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 { .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); width: var(--erp-sidebar-w);
transform: translateX(-100%); transform: translateX(-100%);
transition: transform .2s ease; transition: transform .2s ease;
box-shadow: 0 0 0 1px var(--color-subtle-ash); 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.is-open { transform: translateX(0); }
.erp-sidebar-mobile-toggle { display: inline-flex; } .erp-sidebar-mobile-toggle { display: inline-flex; }
.erp-content { width: 100%; min-width: 0; }
.erp-form-grid { grid-template-columns: 1fr 1fr; } .erp-form-grid { grid-template-columns: 1fr 1fr; }
.erp-field-wide { grid-column: span 2; } .erp-field-wide { grid-column: span 2; }
} }
+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
+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
+9 -4
View File
@@ -28,6 +28,9 @@ MODULE_KEYS: tuple[str, ...] = (
"expense", "expense",
"vacation", "vacation",
"cupang", "cupang",
"malaysia",
"dispatch",
"project",
"expense_approver", "expense_approver",
"vacation_approver", "vacation_approver",
) )
@@ -79,14 +82,14 @@ class UserStore:
@staticmethod @staticmethod
def _normalize_modules(modules: dict[str, Any] | None, *, is_admin: bool) -> dict[str, bool]: 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) base = UserStore._default_modules(is_admin)
if isinstance(modules, dict): if isinstance(modules, dict):
for key in MODULE_KEYS: for key in MODULE_KEYS:
if key in modules: if key in modules:
base[key] = bool(modules[key]) base[key] = bool(modules[key])
if is_admin:
for key in MODULE_KEYS:
base[key] = True
return base return base
def get(self, email: str) -> dict[str, Any] | None: def get(self, email: str) -> dict[str, Any] | None:
@@ -227,7 +230,9 @@ def is_admin(user_rec: dict[str, Any] | None) -> bool:
def has_module(user_rec: dict[str, Any] | None, module: str) -> bool: def has_module(user_rec: dict[str, Any] | None, module: str) -> bool:
if not user_rec: if not user_rec:
return False return False
if is_admin(user_rec): # 슈퍼관리자는 항상 전체 접근. 일반 admin 은 칩(modules) 상태를 따른다 —
# 슈퍼관리자가 admin 의 개별 프로그램 접근을 제어할 수 있도록.
if user_rec.get("is_super_admin"):
return True return True
mods = user_rec.get("modules") or {} mods = user_rec.get("modules") or {}
if mods.get(module): if mods.get(module):
+66 -34
View File
@@ -24,11 +24,30 @@
font-family: inherit; font-family: inherit;
} }
.erp-mod-th { text-align: center; font-size: 11px; padding: 6px 4px; } .erp-mod-group { display: inline-block; margin-left: 6px; padding: 2px 6px; border-radius: 6px;
.erp-mod-cell { text-align: center; padding: 4px; }
.erp-mod-group { display: inline-block; padding: 2px 6px; border-radius: 6px;
background: var(--color-ghost-gray); color: var(--color-midtone-gray); background: var(--color-ghost-gray); color: var(--color-midtone-gray);
font-size: 10px; font-weight: 500; } 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> </style>
</head> </head>
<body class="erp-body"> <body class="erp-body">
@@ -89,15 +108,10 @@
<table class="erp-table" id="users-table"> <table class="erp-table" id="users-table">
<thead> <thead>
<tr> <tr>
<th style="width: 22%;">사용자</th> <th style="width: 200px;">사용자</th>
<th style="width: 80px;">역할</th> <th style="width: 80px;">역할</th>
{% for key in module_keys %} <th>접근 권한 <span class="erp-mod-group">클릭하여 토글</span></th>
<th class="erp-mod-th"> <th style="width: 110px;">최근 로그인</th>
{{ module_labels.get(key, key) }}
{% if key in approver_keys %}<br><span class="erp-mod-group">승인</span>{% endif %}
</th>
{% endfor %}
<th style="width: 120px;">최근 로그인</th>
<th style="width: 130px; text-align: right;">동작</th> <th style="width: 130px; text-align: right;">동작</th>
</tr> </tr>
</thead> </thead>
@@ -130,18 +144,19 @@
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option> <option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
</select> </select>
</td> </td>
<td class="erp-perm-cell">
{% for key in module_keys %} {% for key in module_keys %}
<td class="erp-mod-cell"> <button type="button"
<label class="erp-switch"> class="erp-perm-chip
<input type="checkbox" {% if u.modules[key] %}is-on{% endif %}
data-field="module" {% if key in approver_keys %}is-approver{% endif %}"
data-module="{{ key }}" data-module="{{ key }}"
{% if u.modules[key] %}checked{% endif %} aria-pressed="{{ 'true' if u.modules[key] else 'false' }}"
{% if u.is_super_admin or u.role == 'admin' %}disabled{% endif %} /> {% if u.is_super_admin %}data-locked="true"{% endif %}>
<span class="erp-switch-slider"></span> {{ module_labels.get(key, key) }}{% if key in approver_keys %} 승인{% endif %}
</label> </button>
</td>
{% endfor %} {% endfor %}
</td>
<td style="font-size:12px; color: var(--color-midtone-gray);"> <td style="font-size:12px; color: var(--color-midtone-gray);">
{{ (u.last_login or '미접속')[:16].replace('T', ' ') }} {{ (u.last_login or '미접속')[:16].replace('T', ' ') }}
</td> </td>
@@ -160,8 +175,8 @@
</div> </div>
<p class="erp-section-meta" style="margin-top: var(--sp-16);"> <p class="erp-section-meta" style="margin-top: var(--sp-16);">
<strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자, 권한 변경/삭제 불가. <strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자, 권한 변경/삭제 불가(항상 전체 접근).
admin 역할은 모든 모듈 권한 자동 부여. admin 역할은 기본 전체 ON 이지만 슈퍼관리자가 개별 프로그램 접근을 끌 수 있다.
<code>expense_approver</code> / <code>vacation_approver</code> 는 결재 승인 권한. <code>expense_approver</code> / <code>vacation_approver</code> 는 결재 승인 권한.
</p> </p>
@@ -188,15 +203,22 @@
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 isSuper = tr.dataset.super === "true";
const role = tr.querySelector("select[data-field='role']").value; const adminRole = tr.querySelector("select[data-field='role']").value === "admin";
const adminRole = role === "admin"; tr.querySelectorAll(".erp-perm-chip").forEach((chip) => {
tr.querySelectorAll("input[data-field='module']").forEach((cb) => { if (isSuper) {
if (isSuper || adminRole) { chip.classList.add("is-on");
cb.checked = true; cb.disabled = true; chip.setAttribute("aria-pressed", "true");
chip.setAttribute("data-locked", "true");
} else { } else {
cb.disabled = false; chip.removeAttribute("data-locked");
if (adminRole) {
chip.classList.add("is-on");
chip.setAttribute("aria-pressed", "true");
}
} }
}); });
} }
@@ -204,12 +226,22 @@
tbody.addEventListener("change", (e) => { tbody.addEventListener("change", (e) => {
const tr = e.target.closest("tr"); const tr = e.target.closest("tr");
if (!tr) return; if (!tr) return;
if (e.target.dataset.field === "role") syncModuleSwitches(tr); if (e.target.dataset.field === "role") syncPermChips(tr);
markDirty(tr); markDirty(tr);
}); });
tbody.addEventListener("click", async (e) => { tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button"); // 권한 칩 토글 (잠금 상태 제외)
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; if (!btn) return;
const tr = btn.closest("tr"); const tr = btn.closest("tr");
const email = tr.dataset.email; const email = tr.dataset.email;
@@ -217,8 +249,8 @@
if (btn.dataset.action === "save") { if (btn.dataset.action === "save") {
const role = tr.querySelector("select[data-field='role']").value; const role = tr.querySelector("select[data-field='role']").value;
const modules = {}; const modules = {};
tr.querySelectorAll("input[data-field='module']").forEach((cb) => { tr.querySelectorAll(".erp-perm-chip").forEach((c) => {
modules[cb.dataset.module] = cb.checked; modules[c.dataset.module] = c.classList.contains("is-on");
}); });
btn.disabled = true; btn.textContent = "저장 중…"; btn.disabled = true; btn.textContent = "저장 중…";
try { try {
+3 -2
View File
@@ -4,8 +4,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ page_title or "ERP" }} — DBX Corporation</title> <title>{{ page_title or "ERP" }} — DBX Corporation</title>
<link rel="stylesheet" href="/static/erp.css?v=20260530q" /> <link rel="stylesheet" href="/static/erp.css?v=20260615b" />
<link rel="stylesheet" href="/static/erp-shell.css?v=20260530q" /> <link rel="stylesheet" href="/static/erp-shell.css?v=20260615b" />
<link rel="stylesheet" href="/static/erp-attach-viewer.css" /> <link rel="stylesheet" href="/static/erp-attach-viewer.css" />
{% block head_extra %}{% endblock %} {% block head_extra %}{% endblock %}
</head> </head>
@@ -115,6 +115,7 @@
</div> </div>
<div class="erp-topbar-right"> <div class="erp-topbar-right">
{% block topbar_actions %}{% endblock %}
<div class="erp-user-chip"> <div class="erp-user-chip">
{% if user.picture %} {% if user.picture %}
<img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" /> <img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
+34
View File
@@ -9,6 +9,7 @@
| `return_db` | 반품·교환·CS 데이터 | | `return_db` | 반품·교환·CS 데이터 |
| `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 | | `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 |
| `cupang_db` | 쿠팡 밀크런 출고 묶음 / 출고 라인 / 입고센터 / 박스 입수량 규칙 | | `cupang_db` | 쿠팡 밀크런 출고 묶음 / 출고 라인 / 입고센터 / 박스 입수량 규칙 |
| `malaysia_stock_db` | 말레이시아 창고 재고관리 — 창고/아이템/세트 BOM/입출고 이력/일일 재고조사 |
--- ---
@@ -256,6 +257,39 @@ cd /opt/www/main && docker compose up -d --build
--- ---
## malaysia_stock_db 스키마 / 초기화
DDL: `scripts/sql/malaysia_stock_db_init.sql` (멱등). DB·역할(`malaysia_app`)·테이블·인덱스·트리거·창고/아이템 seed 를 한 번에 생성. **JSON 폴백 없음**`MALAYSIA_STOCK_DB_URL` 미설정 시 모듈이 "설정 필요" 안내만 표시. 상품명은 `itemcode_db` 읽기 전용 재사용.
테이블:
| 테이블 | 용도 |
| --- | --- |
| `warehouses` | 창고(`warehouse_code` UNIQUE). seed: `MY-WH-01 / Malaysia Warehouse` |
| `malaysia_items` | 관리 대상 코드 스코프 + 종류(`individual`/`set`) + 이름 스냅샷. prefix CHECK. seed: 낱개 12 + 세트 5 |
| `set_bom` | 세트 구성표. `set_code`(MY-)·`component_code`(MT/MX/MZ) prefix CHECK, `UNIQUE(set_code, component_code)` |
| `stock_movement` | 입고/출고/조정/조사 이력. `item_code` 낱개만(CHECK), `movement_type` IN/OUT/ADJUST/STOCKTAKE. `ref_type`·`ref_no`(Shopee/Lazada 연동용) |
| `daily_stocktake` | 재고조사 헤더. `status` draft/finalized/cancelled. 같은 날짜+창고 finalized 1건(부분 유니크 인덱스) |
| `daily_stocktake_line` | 조사 라인. `sku_code` 낱개+세트 허용(MD- 금지), `qty>=0`, `UNIQUE(stocktake_id, sku_code)` |
현재고 = `SUM(IN) - SUM(OUT) + SUM(ADJUST) + SUM(STOCKTAKE)`. 재고조사 확정 시 시스템 재고와 조사 최종치의 **차이만** STOCKTAKE movement 로 기록. 세트는 movement 불가, 재고조사/BOM 계산 전용. MD- 뚜껑은 전 영역 제외.
### 운영 서버 초기화 (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 는 건드리지 않음.
---
## 백업 / 복구 (안전 절차) ## 백업 / 복구 (안전 절차)
### 백업 ### 백업
+169
View File
@@ -0,0 +1,169 @@
# 말레이시아 배송 모듈 (dispatch) — TikTok·Shopee 출고관리
> 초보 물류 직원이 엑셀을 열지 않고 웹 화면만 보고 출고 작업을 하도록 만든 모듈.
> main-app ERP 에 편입된 **모듈**이다(별도 앱/포트 아님). 인증은 기존 Google
> OAuth + 권한키 `dispatch` 를 재사용한다.
---
## 1. 무엇을 하나
플랫폼(TikTok·Shopee)에서 매일 받는 파일을 업로드하면, 직원용 **출고 작업
리스트** 를 웹에서 자동 생성한다(엑셀 파일로 만들 필요 없음). 플랫폼마다 올리는
파일이 다르며, 업로드 화면이 플랫폼 선택에 따라 바뀐다.
**TikTok**
| 파일 | 용도 | 업로드 |
| --- | --- | --- |
| 02_Shipping_Label_Packing_Slip.pdf | 실제 포장/라벨 부착 원본 | 선택(보관) |
| 03_TikTok_Order_Export.xlsx | **자동 출고 리스트 기준 데이터** | **필수** |
> 01_Picking_List.pdf 는 더 이상 받지 않는다(피킹 요약은 데이터 엑셀로 생성).
**Shopee**
| 파일 | 용도 | 업로드 |
| --- | --- | --- |
| Shopee Seller Centre.pdf | 택배 송장(라벨) 원본 | 선택(보관) |
| Packing List.Doorstep Delivery.xlsx | **자동 출고 리스트 기준 데이터** | **필수** |
작업 기준 키는 **Order ID / Package ID / Tracking ID / Seller SKU / Quantity** 이고,
**받는 사람 이름·전화·주소**는 출고 작업 카드 표시 + 취합 출고 엑셀 생성을 위해
박스 단위로 **dispatch_db 에 저장한다**(개인정보 — 접근/보관 정책 검토 대상).
### 1박스 묶음 규칙
엑셀 1줄 ≠ 1박스. 아래 우선순위로 1박스를 묶는다.
1. **Package ID** → 2. **Tracking ID** → 3. **Order ID**
같은 박스 안 같은 SKU 는 수량을 합산한다. 결과는 "1박스 = 1카드".
---
## 2. 화면
| 경로 | 화면 |
| --- | --- |
| `GET /dispatch/` | 출고 배치 목록 |
| `GET /dispatch/batches/new` | 업로드(날짜·플랫폼·파일 3개) |
| `POST /dispatch/batches` | 업로드 + 배치 생성 + 파싱 + 박스 저장 |
| `GET /dispatch/batches/{id}` | 출고 작업 리스트(1박스=1카드, 상태 토글·필터·검색) |
| `GET /dispatch/batches/{id}/picking` | SKU별 피킹 요약 |
| `GET /dispatch/batches/{id}/handover` | Kagayaku 전달 리스트(A4 인쇄용) |
| `POST /dispatch/parcels/{id}/toggle` | 작업 상태 1개 토글(AJAX, 즉시 저장) |
작업 상태(카드 버튼, 누르면 초록색): 상품준비 완료 → 포장완료 → 라벨부착 완료 →
Kagayaku 전달 완료 → 택배스캔 확인. 모든 변경은 `dispatch_logs` 에 기록된다.
---
## 3. 설치 / 실행
### 3-1. DB 초기화 (superuser 로 1회)
```bash
read -s -p "dispatch_app password: " APP_PWD; echo
docker exec -i postgres-db psql -U postgres \
-v app_password="$APP_PWD" \
< scripts/sql/dispatch_db_init.sql
```
생성물: DB `dispatch_db`, 역할 `dispatch_app`(CRUD only), 테이블
`dispatch_batches / dispatch_parcels / dispatch_items / dispatch_logs`.
멱등 스크립트라 여러 번 실행해도 기존 데이터를 지우지 않는다.
### 3-2. .env 등록
```
DISPATCH_DB_URL=postgresql://dispatch_app:<APP_PWD>@postgres-db:5432/dispatch_db
```
미설정 시 모듈은 "설정 필요" 안내만 보여주고 앱은 정상 기동한다(JSON 폴백 없음).
### 3-3. 재배포
```bash
cd /opt/www/main && docker compose up -d --build web
```
> `git pull` 후 반드시 `--build`. `restart` 만으로는 코드가 안 바뀐다.
### 3-4. 권한 부여
`/admin` 에서 대상 사용자에게 권한키 `dispatch`(라벨 "말레이시아 배송")를 켠다.
관리자(admin)는 항상 통과.
---
## 4. 업로드 원본 보관
업로드한 PDF/XLSX 원본은 아래에 저장되고 DB 에는 경로만 기록한다.
```
# TikTok
$DATA_DIR/dispatch/2026-06-19/<배치slug>_<HHMMSS>/
02_Shipping_Label_Packing_Slip.pdf
03_TikTok_Order_Export.xlsx
# Shopee
$DATA_DIR/dispatch/2026-06-19/<배치slug>_<HHMMSS>/
Shopee_Seller_Centre.pdf
Packing_List.Doorstep_Delivery.xlsx
```
배치 목록의 **다운로드** 버튼은 위 업로드 원본에 더해, 발송 날짜·고객 이름·전화·
주소·상품명·아이템 코드·주문 수량·오더번호·택배사·송장번호·주문처를 취합한
**출고 엑셀**을 같은 zip 에 함께 넣는다. 엑셀 파일명: `YYYY.MM.DD(Ddd)_tictoc.xlsx`
(Shopee 는 끝이 `_shopee`).
같은 날짜/플랫폼이라도 batch_name 으로 구분해 **새 배치**로 생성된다(덮어쓰지 않음).
예: "2026-06-19 TikTok 오전 출고", "2026-06-19 TikTok 오후 출고".
---
## 5. 파싱 예외 처리
- 필수 컬럼이 없으면 **어떤 컬럼이 없는지** 화면에 표시한다.
- Quantity 가 비면 1 로 처리한다.
- Tracking ID 가 숫자로 읽혀도 문자열로 보존한다(과학표기/`.0` 제거).
- NaN/None 은 빈 문자열로 처리한다.
- 받는 사람 이름/전화/주소 컬럼은 매핑되면 **박스 단위로 보존**한다(작업 카드·엑셀).
- 헤더가 첫 행이 아닐 수 있어(Shopee 는 제목 행이 위에 붙을 수 있음) 상단 몇 행을
훑어 필수 컬럼이 잡히는 첫 행을 헤더로 본다.
가능한 헤더(앞뒤 공백·대소문자 무시, TikTok·Shopee 별칭은 `store.COLUMN_ALIASES`):
Order ID(또는 Order SN), Package ID, Tracking ID(또는 Tracking Number),
Shipping Provider Name(또는 Shipping Option/Courier), Seller SKU(또는 SKU Reference No.),
Product Name, Quantity, Recipient(받는 사람), Phone(전화), Address(주소).
---
## 6. 테스트
박스 묶기/SKU 합산/컬럼 검증 순수 로직 테스트:
```bash
python -m app.modules.dispatch.tests.test_grouping # 앱 의존성 설치 환경에서
```
---
## 7. 기술 메모
- 스택: FastAPI · PostgreSQL(psycopg3) · Jinja2 · 기존 ERP CSS(Bootstrap 미사용,
레포 공통 `erp.css` 재사용) · **openpyxl**(스펙의 pandas 대신, 레포 의존성 경량 유지).
- 코드: `app/modules/dispatch/` (`store.py` 순수 로직 / `parser.py` 엑셀 파싱 /
`pdf_parser.py` 라벨 PDF 받는사람 추출 / `export.py` 취합 출고 엑셀 생성 /
`db.py` DB I/O / `router.py` 라우팅 / `templates/dispatch/` 화면).
- 데이터 출처(하이브리드):
- **xlsx** = 박스/SKU/수량/주문/송장. TikTok 은 표준 컬럼, Shopee 는 SKU·수량이
`product_info` 한 셀에 묶여 있어 `store.parse_shopee_product_info` 로 분해.
- **라벨 PDF** = 받는 사람 이름/전화/주소. xlsx 엔 없거나 가려져 있어 PDF 가 출처.
업로드 시 Order ID 로 박스와 조인(`pdf_parser.extract_recipients`, pdfplumber).
TikTok 라벨은 배송사마다 양식이 달라 `To <이름> (+60)…`(Ninja)·`Receiver <이름>`
(NDD) 등을 모두 처리. Shopee SPX 라벨은 2단 중 왼쪽 열만 읽는다(전화 미표시).
PDF 가 없거나 파싱 실패해도 업로드는 진행(해당 PII 만 빈 값).
- 개인정보 보관: `dispatch_parcels.recipient_name/recipient_phone/recipient_address`.
기존 DB 에는 마이그레이션 `scripts/sql/dispatch_add_recipient_columns.sql` 로 컬럼을
추가한다(멱등). 접근/보관 기간 정책을 별도로 검토할 것.
+69
View File
@@ -0,0 +1,69 @@
# 프로젝트 관리 모듈 (아사나식) — `app/modules/project/`
> 회사(dbxcorp.co.kr) 직원이 프로젝트·서브프로젝트·업무를 달력/타임라인/보드로
> 관리하는 아사나(Asana) 스타일 협업 모듈. DB는 `project_db` 전용.
## 1. 개요
| 항목 | 값 |
| --- | --- |
| URL prefix | `/project` |
| DB | `project_db` (PostgreSQL, 역할 `project_app` CRUD only) |
| 연결 env | `PROJECT_DB_URL` (미설정 시 "설정 필요" 안내, 앱은 죽지 않음) |
| 진입 권한 | 권한키 `project` (관리자 페이지 토글로 직원별 부여, admin 자동) |
| 멤버 배정 후보 | `project` 권한 보유 등록 사용자 자동 목록 (`GET /project/api/assignable-users`) |
| 관리 권한 | 프로젝트 생성/삭제·사용자 배정 = ERP 관리자(`is_admin`) / 서브프로젝트·업무·단계 = 배정 멤버 또는 owner |
| 메일 알림 | `app/mail.py` (stdlib smtplib). 업무 배정/완료 시 관리자에게 발송 |
## 2. 데이터 모델 (`scripts/sql/project_db_init.sql`)
- `projects``parent_id`(self-FK, NULL=최상위 / NOT NULL=서브프로젝트, `ON DELETE CASCADE`), name, description, color, owner_email, start/due_date, status(active|archived).
- `project_members` — 프로젝트↔사용자 배정. role(manager|member), `UNIQUE(project_id, user_email)`.
- `project_stages` — 진행단계(칸반 컬럼). 프로젝트 생성 시 기본 4단계 seed(할 일/진행 중/검토/완료). `is_done_stage`=TRUE 단계로 옮기면 업무 완료 처리.
- `tasks` — 업무. stage_id, title, description, assignee_email/name, priority(low|normal|high), start/due_date, completed_at.
- `task_comments` — 댓글(스켈레톤 테이블, UI는 추후).
- `project_activity` — 활동 이력(created/assigned/completed/stage_changed). 메일 트리거 근거 + 타임라인.
## 3. 화면 / 뷰
- **홈** (`/project/`): 프로젝트 카드 그리드 + "내 업무" 사이드. 관리자는 "새 프로젝트".
- **프로젝트** (`/project/p/{id}`): 좌측 서브프로젝트/멤버, 우측 뷰 토글.
- **달력** — FullCalendar. 업무를 시작~마감 기간으로 표시. 클릭 편집.
- **타임라인** — vis-timeline(간트형). 기간 있는 업무만.
- **보드** — 단계별 칸반. 카드 드래그로 단계 이동(`PUT /api/tasks/{id}` `stage_id`).
- **리스트** — 표.
- 라이브러리는 현재 CDN 로드(스켈레톤). 추후 `app/static/vendor/` self-host 권장.
## 4. 메일 알림
- `app/mail.py` `send_email(...)``SMTP_HOST` 있어야 발송, 없으면 skip. STARTTLS/SSL/평문 지원.
- env: `SMTP_HOST/PORT/USER/PASSWORD/FROM/TLS`, 수신자 `PROJECT_NOTIFY_EMAIL`(쉼표, 비우면 admin 전원).
- 트리거: 업무 신규 배정(생성/수정 시 담당자 변경) · 완료단계 진입. `BackgroundTasks` 비동기, 실패는 로그만.
## 5. 배포
```bash
# 1) DB 생성 (superuser 1회, 컨테이너 postgres-db)
read -s -p "project_app password: " APP_PWD; echo
docker exec -i postgres-db psql -U postgres -v app_password="$APP_PWD" \
< scripts/sql/project_db_init.sql
# 2) /opt/www/main/.env 에 등록
# PROJECT_DB_URL=postgresql://project_app:<APP_PWD>@postgres-db:5432/project_db
# (메일 쓰려면 SMTP_* 추가)
# 3) 재배포 (git pull 후 반드시 --build)
cd /opt/www/main && docker compose up -d --build web
```
## 6. 댓글 · 첨부 · 알림센터 · 단계편집 (구현됨)
- **댓글** `task_comments` — 업무 모달 하단. 등록/삭제(본인·관리자). 새 댓글 시 관련자 인앱 알림.
- **첨부** `task_attachments` — 업무 모달. 파일 업로드(최대 20MB)/다운로드/삭제. 실제 파일은 `DATA_DIR/project/<task_id>/<uuid>.<ext>`, DB엔 메타만. 첨부 디렉토리는 운영 볼륨(DATA_DIR)에 저장돼 재배포에도 보존.
- **단계 편집** — 보드 칸반 헤더에서 단계 이름변경/완료토글/좌우 이동(순서)/삭제, 트레일링 "+ 단계 추가". `PUT .../stages/order`, `PUT .../stages/{id}`.
- **알림센터(인앱)** `project_notifications` — 배정/완료/댓글 시 수신자별 알림 생성. 우측 상단 벨(미읽음 배지) → `/project/inbox`. 항목 클릭=읽음, "모두 읽음". 본인 행동은 알림 제외.
- 마이그레이션: `scripts/sql/project_db_002_attachments_notifications.sql` (첨부·알림 테이블 + 권한).
## 7. 추후 단계
태그 · 하위업무(체크리스트) · 검색/필터 · 업무 정렬 영속화 · 칸반 단계 드래그 정렬 · 멘션.
+3 -1
View File
@@ -20,13 +20,14 @@
| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 | | 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 |
| 쿠팡 밀크런 | 쿠팡 출고 일정 관리 (`app/modules/cupang/`) — 월간 달력 / 출고 묶음(헤더+라인) / 박스 입수량 자동계산 / 입고센터 관리 / 엑셀 내보내기. 상품은 `itemcode_db` 읽기 전용 참조 | | 쿠팡 밀크런 | 쿠팡 출고 일정 관리 (`app/modules/cupang/`) — 월간 달력 / 출고 묶음(헤더+라인) / 박스 입수량 자동계산 / 입고센터 관리 / 엑셀 내보내기. 상품은 `itemcode_db` 읽기 전용 참조 |
| 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 | | 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 |
| 말레이시아 재고 | 말레이시아 창고 재고관리 (`app/modules/malaysia/`) — 낱개 입출고/조정, 세트 BOM, 일일 재고조사(세트 분해 자동 반영), 현재고 현황. 상품명은 `itemcode_db` 읽기 전용 참조 |
### 권한 키 (`MODULE_KEYS`) ### 권한 키 (`MODULE_KEYS`)
| 키 | 종류 | 설명 | | 키 | 종류 | 설명 |
| --- | --- | --- | | --- | --- | --- |
| `corm` / `order` | 접근 | 외부 모듈 진입 | | `corm` / `order` | 접근 | 외부 모듈 진입 |
| `expense` / `vacation` / `cupang` | 접근 | 내부 모듈 진입 | | `expense` / `vacation` / `cupang` / `malaysia` | 접근 | 내부 모듈 진입 |
| `expense_approver` | 결재 | 개인경비 승인/반려/정산 | | `expense_approver` | 결재 | 개인경비 승인/반려/정산 |
| `vacation_approver` | 결재 | 휴가 승인/반려 (모듈 미개발) | | `vacation_approver` | 결재 | 휴가 승인/반려 (모듈 미개발) |
@@ -89,6 +90,7 @@ app/modules/<name>/
| `expense_db` | 개인경비 / 법인카드 / 정산 (`EXPENSE_DB_URL` 미설정 시 JSON 폴백) | | `expense_db` | 개인경비 / 법인카드 / 정산 (`EXPENSE_DB_URL` 미설정 시 JSON 폴백) |
| `cupang_db` | 쿠팡 밀크런 출고/입고센터/박스규칙 (`CUPANG_DB_URL` 필수, JSON 폴백 없음) | | `cupang_db` | 쿠팡 밀크런 출고/입고센터/박스규칙 (`CUPANG_DB_URL` 필수, JSON 폴백 없음) |
| `vacation_db` | 휴가 신청/공휴일/연차잔여 (`VACATION_DB_URL` 필수, JSON 폴백 없음) | | `vacation_db` | 휴가 신청/공휴일/연차잔여 (`VACATION_DB_URL` 필수, JSON 폴백 없음) |
| `malaysia_stock_db` | 말레이시아 창고 재고/세트BOM/재고조사 (`MALAYSIA_STOCK_DB_URL` 필수, JSON 폴백 없음) |
> 신규 DB가 필요하면 **승인 요청 후** 생성하며, 이름은 `_db`로 끝낸다. 상세는 `DATABASES.md`. > 신규 DB가 필요하면 **승인 요청 후** 생성하며, 이름은 `_db`로 끝낸다. 상세는 `DATABASES.md`.
+1
View File
@@ -8,4 +8,5 @@ python-dotenv>=1.0
psycopg[binary,pool]>=3.2 psycopg[binary,pool]>=3.2
python-multipart>=0.0.20 python-multipart>=0.0.20
openpyxl>=3.1 openpyxl>=3.1
pdfplumber>=0.11
pillow>=10.0 pillow>=10.0
+27
View File
@@ -0,0 +1,27 @@
-- =====================================================================
-- dispatch_db 마이그레이션 — 말레이시아 재고 차감 1회성 기록
-- =====================================================================
-- 멱등(idempotent): 여러 번 실행해도 안전.
--
-- 배경: 배송 배치의 그날 출고를 말레이시아 재고관리(OUT)로 1회만 반영하기 위해,
-- 날짜 단위로 차감 완료를 기록한다. 같은 날짜를 다시 차감하면 재고가
-- 이중으로 빠지므로 이 표의 존재 여부로 재실행을 차단한다.
--
-- 실행:
-- docker exec -i postgres-db psql -U postgres -d dispatch_db \
-- < scripts/sql/dispatch_add_deductions.sql
-- =====================================================================
\set ON_ERROR_STOP on
\connect dispatch_db
CREATE TABLE IF NOT EXISTS dispatch_stock_deductions (
dispatch_date DATE PRIMARY KEY,
warehouse_code TEXT NOT NULL DEFAULT '',
single_lines INTEGER NOT NULL DEFAULT 0,
combo_lines INTEGER NOT NULL DEFAULT 0,
worker_name TEXT NOT NULL DEFAULT '',
deducted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
SELECT 'dispatch_stock_deductions ready' AS status;
@@ -0,0 +1,24 @@
-- =====================================================================
-- dispatch_db 마이그레이션 — 박스에 받는 사람(PII) 컬럼 추가
-- =====================================================================
-- 멱등(idempotent): 여러 번 실행해도 안전(ADD COLUMN IF NOT EXISTS).
--
-- 배경: 기존 dispatch 모듈은 개인정보(이름/주소/전화)를 저장하지 않는 구조였다.
-- 운영 요청으로 출고 작업 카드 표시 + 출고 엑셀(취합본) 생성을 위해
-- 받는 사람 정보를 박스 단위로 보관하도록 정책을 변경한다.
--
-- 실행 (운영 PostgreSQL 컨테이너명: postgres-db):
-- docker exec -i postgres-db psql -U postgres -d dispatch_db \
-- < scripts/sql/dispatch_add_recipient_columns.sql
--
-- ⚠️ 개인정보 컬럼이다. 접근 권한/보관 기간 정책을 함께 검토할 것.
-- =====================================================================
\set ON_ERROR_STOP on
\connect dispatch_db
ALTER TABLE dispatch_parcels ADD COLUMN IF NOT EXISTS recipient_name TEXT NOT NULL DEFAULT '';
ALTER TABLE dispatch_parcels ADD COLUMN IF NOT EXISTS recipient_phone TEXT NOT NULL DEFAULT '';
ALTER TABLE dispatch_parcels ADD COLUMN IF NOT EXISTS recipient_address TEXT NOT NULL DEFAULT '';
SELECT 'dispatch_parcels recipient columns ready' AS status;
+171
View File
@@ -0,0 +1,171 @@
-- =====================================================================
-- dispatch_db 초기화 스크립트 (PostgreSQL) — 말레이시아 TikTok 출고관리
-- =====================================================================
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
--
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
--
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
--
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
-- read -s -p "dispatch_app password: " APP_PWD; echo
-- docker exec -i postgres-db psql -U postgres \
-- -v app_password="$APP_PWD" \
-- < scripts/sql/dispatch_db_init.sql
--
-- 2) main-app .env 에 연결 정보 등록
-- DISPATCH_DB_URL=postgresql://dispatch_app:<APP_PWD>@postgres-db:5432/dispatch_db
--
-- 3) main-app 재기동
-- cd /opt/www/main && docker compose up -d --build web
--
-- 주의:
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
-- - 받는 사람 정보(이름/전화/주소)는 출고 작업 카드 표시 + 출고 엑셀 생성을
-- 위해 박스 단위로 보관한다(개인정보 — 접근/보관 정책 검토 대상).
-- - 작업 기준 키: order_id / package_id / tracking_id / seller_sku / quantity.
-- =====================================================================
\set ON_ERROR_STOP on
-- DB 가 없을 때만 생성
SELECT 'CREATE DATABASE dispatch_db ENCODING ''UTF8'' TEMPLATE template0'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'dispatch_db')
\gexec
-- 앱 전용 로그인 역할
SELECT 'CREATE ROLE dispatch_app LOGIN PASSWORD ' || quote_literal(:'app_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dispatch_app')
\gexec
-- 항상 최신 비밀번호로 동기화
SELECT 'ALTER ROLE dispatch_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
\gexec
GRANT CONNECT ON DATABASE dispatch_db TO dispatch_app;
-- dispatch_db 컨텍스트로 전환
\connect dispatch_db
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
CREATE OR REPLACE FUNCTION dispatch_set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ════════════════════════════════════════════════════════════
-- 1) 출고 배치 (하루 1회~여러 회 업로드 단위)
-- 같은 날짜/플랫폼이라도 batch_name 으로 구분해 여러 배치 생성 가능.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS dispatch_batches (
id BIGSERIAL PRIMARY KEY,
platform TEXT NOT NULL DEFAULT 'TikTok',
dispatch_date DATE NOT NULL,
batch_name TEXT NOT NULL DEFAULT '',
picking_pdf_path TEXT NOT NULL DEFAULT '',
label_pdf_path TEXT NOT NULL DEFAULT '',
order_export_path TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dispatch_batches_date ON dispatch_batches (dispatch_date DESC);
DROP TRIGGER IF EXISTS trg_dispatch_batches_updated ON dispatch_batches;
CREATE TRIGGER trg_dispatch_batches_updated
BEFORE UPDATE ON dispatch_batches
FOR EACH ROW EXECUTE FUNCTION dispatch_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 2) 출고 박스 (1박스 = 1행 = 1작업카드)
-- 묶음 기준: package_id > tracking_id > order_id (파서가 결정).
-- 받는 사람(recipient_*) 정보 보관 — 작업 카드 표시 + 출고 엑셀 생성용.
-- seq 는 배치 내 박스 표시 순번(No).
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS dispatch_parcels (
id BIGSERIAL PRIMARY KEY,
batch_id BIGINT NOT NULL REFERENCES dispatch_batches(id) ON DELETE CASCADE,
seq INTEGER NOT NULL DEFAULT 0,
order_id TEXT NOT NULL DEFAULT '',
package_id TEXT NOT NULL DEFAULT '',
tracking_id TEXT NOT NULL DEFAULT '',
shipping_provider TEXT NOT NULL DEFAULT '',
recipient_name TEXT NOT NULL DEFAULT '',
recipient_phone TEXT NOT NULL DEFAULT '',
recipient_address TEXT NOT NULL DEFAULT '',
product_ready BOOLEAN NOT NULL DEFAULT FALSE,
packed BOOLEAN NOT NULL DEFAULT FALSE,
label_attached BOOLEAN NOT NULL DEFAULT FALSE,
handed_to_kagayaku BOOLEAN NOT NULL DEFAULT FALSE,
courier_scanned BOOLEAN NOT NULL DEFAULT FALSE,
memo TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dispatch_parcels_batch ON dispatch_parcels (batch_id, seq);
CREATE INDEX IF NOT EXISTS idx_dispatch_parcels_order ON dispatch_parcels (order_id);
CREATE INDEX IF NOT EXISTS idx_dispatch_parcels_tracking ON dispatch_parcels (tracking_id);
CREATE INDEX IF NOT EXISTS idx_dispatch_parcels_package ON dispatch_parcels (package_id);
DROP TRIGGER IF EXISTS trg_dispatch_parcels_updated ON dispatch_parcels;
CREATE TRIGGER trg_dispatch_parcels_updated
BEFORE UPDATE ON dispatch_parcels
FOR EACH ROW EXECUTE FUNCTION dispatch_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 3) 박스 안 상품 (1박스 N상품, 같은 SKU 는 수량 합산되어 1행)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS dispatch_items (
id BIGSERIAL PRIMARY KEY,
parcel_id BIGINT NOT NULL REFERENCES dispatch_parcels(id) ON DELETE CASCADE,
seller_sku TEXT NOT NULL DEFAULT '',
product_name TEXT NOT NULL DEFAULT '',
quantity INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_dispatch_items_parcel ON dispatch_items (parcel_id);
CREATE INDEX IF NOT EXISTS idx_dispatch_items_sku ON dispatch_items (seller_sku);
-- ════════════════════════════════════════════════════════════
-- 4) 작업 상태 변경 로그 (감사/추적용)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS dispatch_logs (
id BIGSERIAL PRIMARY KEY,
parcel_id BIGINT NOT NULL REFERENCES dispatch_parcels(id) ON DELETE CASCADE,
action TEXT NOT NULL DEFAULT '',
old_value TEXT NOT NULL DEFAULT '',
new_value TEXT NOT NULL DEFAULT '',
worker_name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dispatch_logs_parcel ON dispatch_logs (parcel_id);
-- ════════════════════════════════════════════════════════════
-- 5) 말레이시아 재고 차감 1회성 기록 (날짜 단위 멱등 가드)
-- 그날 출고를 말레이시아 재고관리(OUT)로 1회만 반영. 존재 시 재차감 차단.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS dispatch_stock_deductions (
dispatch_date DATE PRIMARY KEY,
warehouse_code TEXT NOT NULL DEFAULT '',
single_lines INTEGER NOT NULL DEFAULT 0,
combo_lines INTEGER NOT NULL DEFAULT 0,
worker_name TEXT NOT NULL DEFAULT '',
deducted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ════════════════════════════════════════════════════════════
-- 6) 권한 (dispatch_app: CRUD only)
-- ════════════════════════════════════════════════════════════
GRANT USAGE ON SCHEMA public TO dispatch_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON
dispatch_batches, dispatch_parcels, dispatch_items, dispatch_logs,
dispatch_stock_deductions
TO dispatch_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO dispatch_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO dispatch_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO dispatch_app;
SELECT 'dispatch_db ready' AS status;
@@ -0,0 +1,14 @@
-- ════════════════════════════════════════════════════════════
-- 마이그레이션: 콤보 세트 MY-0006 (MALAYSIA Special Set) 등록
-- malaysia_items 에 신규 세트 1종을 추가한다(kind='set').
-- 세트 BOM 구성은 itemcode_db.set_components 에서 읽으므로 여기서
-- 다루지 않는다(기존 콤보 구성 변경도 itemcode_db 에 이미 반영됨).
-- 기존 운영 malaysia_stock_db 에 적용(신규 설치는 init 에 이미 포함).
-- superuser/owner 로 실행. 멱등.
-- ════════════════════════════════════════════════════════════
INSERT INTO malaysia_items (item_code, item_name, kind, sort_order) VALUES
('MY-0006', 'MALAYSIA Special Set', 'set', 26)
ON CONFLICT (item_code) DO UPDATE
SET item_name = EXCLUDED.item_name,
kind = EXCLUDED.kind,
active = TRUE;
@@ -0,0 +1,19 @@
-- ════════════════════════════════════════════════════════════
-- 마이그레이션: 신규 세트 MY-1001~MY-1006 등록
-- malaysia_items 에 신규 세트 6종을 추가한다(kind='set').
-- 기존 MY-0001~0006, MY-9991 은 그대로 유지(대체 아님, 별개 신규 추가).
-- 세트 BOM 구성은 itemcode_db.set_components 에서 읽으므로 여기서
-- 다루지 않는다(itemcode_db 쪽 BOM은 이미 등록됨 확인됨).
-- 기존 운영 malaysia_stock_db 에 적용. superuser/owner 로 실행. 멱등.
-- ════════════════════════════════════════════════════════════
INSERT INTO malaysia_items (item_code, item_name, kind, sort_order) VALUES
('MY-1001', 'Launching Special', 'set', 28),
('MY-1002', 'Starter Set', 'set', 29),
('MY-1003', 'Must Have Set', 'set', 30),
('MY-1004', 'Premium Set', 'set', 31),
('MY-1005', 'Ultimate Set', 'set', 32),
('MY-1006', 'Malaysia Special Set', 'set', 33)
ON CONFLICT (item_code) DO UPDATE
SET item_name = EXCLUDED.item_name,
kind = EXCLUDED.kind,
active = TRUE;
+318
View File
@@ -0,0 +1,318 @@
-- =====================================================================
-- malaysia_stock_db 초기화 스크립트 (PostgreSQL) — 말레이시아 창고 재고관리
-- =====================================================================
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
--
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
--
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
--
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
-- 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
--
-- 2) main-app .env 에 연결 정보 등록
-- MALAYSIA_STOCK_DB_URL=postgresql://malaysia_app:<APP_PWD>@postgres-db:5432/malaysia_stock_db
--
-- 3) main-app 재기동
-- cd /opt/www/main && docker compose up -d --build
--
-- 주의:
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
-- - itemcode_db 는 이 스크립트가 건드리지 않는다(상품명은 읽기 전용 참조).
-- - 재고 계산 원칙:
-- 현재고 = SUM(IN) - SUM(OUT) + SUM(ADJUST 부호) + SUM(STOCKTAKE 부호)
-- ADJUST/STOCKTAKE 는 음수 qty 허용(±). IN/OUT 은 양수만.
-- =====================================================================
\set ON_ERROR_STOP on
-- DB 가 없을 때만 생성
SELECT 'CREATE DATABASE malaysia_stock_db ENCODING ''UTF8'' TEMPLATE template0'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'malaysia_stock_db')
\gexec
-- 앱 전용 로그인 역할 (cupang_app 패턴과 동일)
SELECT 'CREATE ROLE malaysia_app LOGIN PASSWORD ' || quote_literal(:'app_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'malaysia_app')
\gexec
-- 항상 최신 비밀번호로 동기화
SELECT 'ALTER ROLE malaysia_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
\gexec
GRANT CONNECT ON DATABASE malaysia_stock_db TO malaysia_app;
-- malaysia_stock_db 컨텍스트로 전환
\connect malaysia_stock_db
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
CREATE OR REPLACE FUNCTION malaysia_set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ════════════════════════════════════════════════════════════
-- 1) 창고
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS warehouses (
id BIGSERIAL PRIMARY KEY,
warehouse_code TEXT NOT NULL UNIQUE,
warehouse_name TEXT NOT NULL,
country TEXT NOT NULL DEFAULT 'MY',
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_warehouses_updated ON warehouses;
CREATE TRIGGER trg_warehouses_updated
BEFORE UPDATE ON warehouses
FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 2) 재고관리 대상 아이템(스코프 카탈로그)
-- 상품 마스터를 복제하는 것이 아니라, "말레이시아 창고에서 관리하는
-- 코드 목록과 종류(낱개/세트)"만 보관한다. item_name 은 스냅샷이며
-- itemcode_db(읽기 전용)에서 최신 명칭을 덮어쓸 수 있다.
-- kind: 'individual'(MT/MX/MZ) | 'set'(MY). MD- 뚜껑은 제외(미등록).
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS malaysia_items (
id BIGSERIAL PRIMARY KEY,
item_code TEXT NOT NULL UNIQUE,
item_name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL CHECK (kind IN ('individual', 'set')),
active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- prefix 규칙: 낱개는 MT-/MX-/MZ-, 세트는 MY-. MD- 금지.
CHECK (
(kind = 'individual' AND (item_code LIKE 'MT-%' OR item_code LIKE 'MX-%' OR item_code LIKE 'MZ-%'))
OR
(kind = 'set' AND item_code LIKE 'MY-%')
)
);
CREATE INDEX IF NOT EXISTS idx_malaysia_items_kind ON malaysia_items (kind);
DROP TRIGGER IF EXISTS trg_malaysia_items_updated ON malaysia_items;
CREATE TRIGGER trg_malaysia_items_updated
BEFORE UPDATE ON malaysia_items
FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 3) 세트 구성표 (BOM)
-- set_code(MY-) 안에 component_code(MT-/MX-/MZ-) 가 몇 개 들어가는지.
-- MY- 가 MY- 를 포함하는 중첩은 불가(component 는 MY- 금지). MD- 금지.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS set_bom (
id BIGSERIAL PRIMARY KEY,
set_code TEXT NOT NULL CHECK (set_code LIKE 'MY-%'),
component_code TEXT NOT NULL CHECK (
component_code LIKE 'MT-%'
OR component_code LIKE 'MX-%'
OR component_code LIKE 'MZ-%'),
component_qty INTEGER NOT NULL CHECK (component_qty > 0),
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (set_code, component_code)
);
CREATE INDEX IF NOT EXISTS idx_set_bom_set ON set_bom (set_code);
DROP TRIGGER IF EXISTS trg_set_bom_updated ON set_bom;
CREATE TRIGGER trg_set_bom_updated
BEFORE UPDATE ON set_bom
FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 4) 재고 이동 이력 (입고/출고/조정/재고조사 반영)
-- item_code 는 낱개(MT-/MX-/MZ-)만 저장한다. MY-/MD- 는 movement 금지.
-- qty 부호 규칙:
-- IN/OUT → 양수만(방향은 movement_type 으로 구분)
-- ADJUST → 음수 허용(±)
-- STOCKTAKE → 음수 허용(±, 재고조사 차이 반영분)
-- 현재고 = SUM(IN) - SUM(OUT) + SUM(ADJUST) + SUM(STOCKTAKE)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS stock_movement (
id BIGSERIAL PRIMARY KEY,
movement_date DATE NOT NULL,
warehouse_code TEXT NOT NULL REFERENCES warehouses(warehouse_code),
item_code TEXT NOT NULL CHECK (
item_code LIKE 'MT-%'
OR item_code LIKE 'MX-%'
OR item_code LIKE 'MZ-%'),
movement_type TEXT NOT NULL CHECK (movement_type IN ('IN', 'OUT', 'ADJUST', 'STOCKTAKE')),
qty INTEGER NOT NULL,
ref_type TEXT NOT NULL DEFAULT '', -- 예: shopee/lazada/stocktake/manual
ref_no TEXT NOT NULL DEFAULT '', -- 외부 주문번호/조사 id 등
memo TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- IN/OUT 은 양수만. ADJUST/STOCKTAKE 만 음수 허용.
CHECK (
(movement_type IN ('IN', 'OUT') AND qty > 0)
OR movement_type IN ('ADJUST', 'STOCKTAKE')
)
);
CREATE INDEX IF NOT EXISTS idx_stock_mv_wh_item ON stock_movement (warehouse_code, item_code);
CREATE INDEX IF NOT EXISTS idx_stock_mv_date ON stock_movement (movement_date);
CREATE INDEX IF NOT EXISTS idx_stock_mv_type ON stock_movement (movement_type);
CREATE INDEX IF NOT EXISTS idx_stock_mv_ref ON stock_movement (ref_type, ref_no);
-- ════════════════════════════════════════════════════════════
-- 4-b) 콤보(세트) 출고 원장 (세트 단위)
-- set_code 는 MY- 만. 세트 출고 시 stock_movement(낱개 분해)와 함께
-- 여기에 콤보 단위 OUT 을 1행 기록한다(같은 트랜잭션).
-- 콤보 재고현황 = 최근 재고조사 콤보수량 − 재고조사일 이후 OUT 합.
-- 콤보 입고는 업무상 불가(낱개로만 입고) → OUT/ADJUST 만 허용.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS set_movement (
id BIGSERIAL PRIMARY KEY,
movement_date DATE NOT NULL,
warehouse_code TEXT NOT NULL REFERENCES warehouses(warehouse_code),
set_code TEXT NOT NULL CHECK (set_code LIKE 'MY-%'),
movement_type TEXT NOT NULL DEFAULT 'OUT' CHECK (movement_type IN ('OUT', 'ADJUST')),
qty INTEGER NOT NULL,
ref_type TEXT NOT NULL DEFAULT '',
ref_no TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- OUT 은 양수만. ADJUST 만 음수 허용.
CHECK ((movement_type = 'OUT' AND qty > 0) OR movement_type = 'ADJUST')
);
CREATE INDEX IF NOT EXISTS idx_set_mv_wh_set ON set_movement (warehouse_code, set_code);
CREATE INDEX IF NOT EXISTS idx_set_mv_date ON set_movement (movement_date);
-- ════════════════════════════════════════════════════════════
-- 5) 일일 재고조사 (헤더)
-- 같은 날짜 + 같은 창고에 finalized 는 1건만 허용(부분 유니크 인덱스).
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS daily_stocktake (
id BIGSERIAL PRIMARY KEY,
stocktake_date DATE NOT NULL,
warehouse_code TEXT NOT NULL REFERENCES warehouses(warehouse_code),
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'finalized', 'cancelled')),
memo TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finalized_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_stocktake_date ON daily_stocktake (stocktake_date);
CREATE INDEX IF NOT EXISTS idx_stocktake_wh ON daily_stocktake (warehouse_code);
-- 같은 날짜+창고 finalized 중복 방지
CREATE UNIQUE INDEX IF NOT EXISTS uq_stocktake_finalized
ON daily_stocktake (stocktake_date, warehouse_code)
WHERE status = 'finalized';
DROP TRIGGER IF EXISTS trg_stocktake_updated ON daily_stocktake;
CREATE TRIGGER trg_stocktake_updated
BEFORE UPDATE ON daily_stocktake
FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 6) 일일 재고조사 라인
-- sku_code: 낱개(MT-/MX-/MZ-) 또는 세트(MY-) 허용. MD- 금지.
-- 같은 조사 안에서 같은 sku_code 중복 금지.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS daily_stocktake_line (
id BIGSERIAL PRIMARY KEY,
stocktake_id BIGINT NOT NULL REFERENCES daily_stocktake(id) ON DELETE CASCADE,
sku_code TEXT NOT NULL CHECK (
sku_code LIKE 'MT-%'
OR sku_code LIKE 'MX-%'
OR sku_code LIKE 'MZ-%'
OR sku_code LIKE 'MY-%'),
qty INTEGER NOT NULL CHECK (qty >= 0),
memo TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (stocktake_id, sku_code)
);
CREATE INDEX IF NOT EXISTS idx_stocktake_line_st ON daily_stocktake_line (stocktake_id);
DROP TRIGGER IF EXISTS trg_stocktake_line_updated ON daily_stocktake_line;
CREATE TRIGGER trg_stocktake_line_updated
BEFORE UPDATE ON daily_stocktake_line
FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 6.5) 일일 재고조사 랙(rack) 입력
-- 창고 랙(A/B면, 행/열/분할 칸)별로 "어떤 SKU 가 한 박스당 몇 개씩
-- 몇 박스 있는지"를 기록한다. 칸 단위 입력 → 서버가 SKU 별
-- qty = SUM(units_per_box * box_count) 로 집계해 daily_stocktake_line 을
-- 재생성한다(랙이 라인의 단일 출처). 한 칸(파렛트)에 여러 SKU 가능.
-- cell_code 예: 'A3-1-1' (면 A, 행 3, 열 1, 분할 1).
-- sku_code: 낱개(MT-/MX-/MZ-) 또는 세트(MY-) 허용. MD- 금지.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS daily_stocktake_rack (
id BIGSERIAL PRIMARY KEY,
stocktake_id BIGINT NOT NULL REFERENCES daily_stocktake(id) ON DELETE CASCADE,
cell_code TEXT NOT NULL, -- 랙 위치(예: A3-1-1)
line_no INTEGER NOT NULL DEFAULT 0, -- 칸 안 입력 순서
sku_code TEXT NOT NULL CHECK (
sku_code LIKE 'MT-%'
OR sku_code LIKE 'MX-%'
OR sku_code LIKE 'MZ-%'
OR sku_code LIKE 'MY-%'
OR sku_code LIKE 'MD-%'), -- 뚜껑: 위치확인용(재고 집계 제외)
units_per_box INTEGER NOT NULL CHECK (units_per_box > 0),
box_count INTEGER NOT NULL CHECK (box_count > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_stocktake_rack_st ON daily_stocktake_rack (stocktake_id);
CREATE INDEX IF NOT EXISTS idx_stocktake_rack_cell ON daily_stocktake_rack (stocktake_id, cell_code);
-- ════════════════════════════════════════════════════════════
-- 7) seed — 기본 창고 + 재고관리 대상 아이템
-- ════════════════════════════════════════════════════════════
INSERT INTO warehouses (warehouse_code, warehouse_name, country) VALUES
('MY-WH-01', 'Malaysia Warehouse', 'MY')
ON CONFLICT (warehouse_code) DO NOTHING;
-- 낱개 아이템 (MT-/MX-/MZ-). MD- 뚜껑은 의도적으로 제외.
INSERT INTO malaysia_items (item_code, item_name, kind, sort_order) VALUES
('MT-0320', 'Miracle Container 320ml', 'individual', 1),
('MT-0550', 'Miracle Container 550ml', 'individual', 2),
('MT-0750', 'Miracle Container 750ml', 'individual', 3),
('MT-0950', 'Miracle Container 950ml', 'individual', 4),
('MT-1800', 'Miracle Container 1800ml', 'individual', 5),
('MT-2200', 'Miracle Container 2200ml', 'individual', 6),
('MT-3200', 'Miracle Container 3200ml', 'individual', 7),
('MT-5000', 'Miracle Container 5000ml', 'individual', 8),
('MX-0001', 'Miracle Bag 3 Types', 'individual', 9),
('MX-0003', 'Miracle Bag 5 pcs', 'individual', 10),
('MZ-0001', 'Miracle Zipperbag 2.5L', 'individual', 11),
('MZ-0002', 'Miracle Zipperbag 6.0L', 'individual', 12),
('MY-0001', 'Launching Special', 'set', 21),
('MY-0002', 'Starter Set', 'set', 22),
('MY-0003', 'Essential Set', 'set', 23),
('MY-0004', 'Premium Set', 'set', 24),
('MY-0005', 'Ultimate Set', 'set', 25),
('MY-0006', 'MALAYSIA Special Set', 'set', 26),
('MY-9991', 'Combo 2.5L+6L', 'set', 27)
ON CONFLICT (item_code) DO NOTHING;
-- ════════════════════════════════════════════════════════════
-- 8) 권한 (malaysia_app: CRUD only)
-- ════════════════════════════════════════════════════════════
GRANT USAGE ON SCHEMA public TO malaysia_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON
warehouses, malaysia_items, set_bom, stock_movement, set_movement,
daily_stocktake, daily_stocktake_line, daily_stocktake_rack
TO malaysia_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO malaysia_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO malaysia_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO malaysia_app;
SELECT 'malaysia_stock_db ready' AS status;
@@ -0,0 +1,19 @@
-- ════════════════════════════════════════════════════════════
-- 마이그레이션: 랙 입력에 뚜껑(MD-) 허용
-- daily_stocktake_rack.sku_code CHECK 제약에 'MD-%' 를 추가한다.
-- 뚜껑은 위치 확인용으로만 랙에 두며, 재고 집계(daily_stocktake_line)
-- 에는 반영되지 않는다(애플리케이션 aggregate_rack 에서 제외).
-- 기존 운영 malaysia_stock_db 에 적용(신규 설치는 init 에 이미 포함).
-- superuser/owner 로 실행. 멱등.
-- ════════════════════════════════════════════════════════════
ALTER TABLE daily_stocktake_rack
DROP CONSTRAINT IF EXISTS daily_stocktake_rack_sku_code_check;
ALTER TABLE daily_stocktake_rack
ADD CONSTRAINT daily_stocktake_rack_sku_code_check CHECK (
sku_code LIKE 'MT-%'
OR sku_code LIKE 'MX-%'
OR sku_code LIKE 'MZ-%'
OR sku_code LIKE 'MY-%'
OR sku_code LIKE 'MD-%'
);
@@ -0,0 +1,26 @@
-- ════════════════════════════════════════════════════════════
-- 마이그레이션: 콤보(세트) 출고 원장 set_movement 추가
-- 기존 운영 malaysia_stock_db 에 적용(신규 설치는 init 에 이미 포함).
-- superuser/owner 로 실행. 멱등(IF NOT EXISTS).
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS set_movement (
id BIGSERIAL PRIMARY KEY,
movement_date DATE NOT NULL,
warehouse_code TEXT NOT NULL REFERENCES warehouses(warehouse_code),
set_code TEXT NOT NULL CHECK (set_code LIKE 'MY-%'),
movement_type TEXT NOT NULL DEFAULT 'OUT' CHECK (movement_type IN ('OUT', 'ADJUST')),
qty INTEGER NOT NULL,
ref_type TEXT NOT NULL DEFAULT '',
ref_no TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK ((movement_type = 'OUT' AND qty > 0) OR movement_type = 'ADJUST')
);
CREATE INDEX IF NOT EXISTS idx_set_mv_wh_set ON set_movement (warehouse_code, set_code);
CREATE INDEX IF NOT EXISTS idx_set_mv_date ON set_movement (movement_date);
GRANT SELECT, INSERT, UPDATE, DELETE ON set_movement TO malaysia_app;
GRANT USAGE, SELECT ON SEQUENCE set_movement_id_seq TO malaysia_app;
SELECT 'set_movement ready' AS status;
@@ -0,0 +1,57 @@
-- =====================================================================
-- project_db 마이그레이션 002 — 첨부파일 + 알림센터
-- =====================================================================
-- 멱등(idempotent). DROP/TRUNCATE 없음.
--
-- 실행:
-- docker exec -i postgres-db psql -U postgres -d project_db \
-- < scripts/sql/project_db_002_attachments_notifications.sql
--
-- (task_comments 는 init 스크립트에 이미 있음 — 여기서 다루지 않음)
-- =====================================================================
\set ON_ERROR_STOP on
\connect project_db
-- ════════════════════════════════════════════════════════════
-- 업무 첨부파일 — 실제 파일은 DATA_DIR/project/<task_id>/ 에 저장.
-- 여기엔 메타데이터만(원본명/저장명/크기/타입/업로더).
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS task_attachments (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
filename TEXT NOT NULL, -- 사용자에게 보일 원본 파일명
stored_name TEXT NOT NULL, -- 디스크 저장명(UUID.ext)
content_type TEXT NOT NULL DEFAULT '',
size_bytes BIGINT NOT NULL DEFAULT 0,
uploaded_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_task_attachments_task ON task_attachments (task_id, created_at);
-- ════════════════════════════════════════════════════════════
-- 알림센터 — 사용자별 인앱 알림(아사나 Inbox).
-- 업무 배정/완료/댓글/단계변경 시 관련자에게 1행씩 생성.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS project_notifications (
id BIGSERIAL PRIMARY KEY,
recipient_email TEXT NOT NULL,
actor_email TEXT NOT NULL DEFAULT '',
project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE,
task_id BIGINT REFERENCES tasks(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- assigned | completed | comment | stage_changed
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_project_notif_recipient
ON project_notifications (recipient_email, is_read, created_at DESC);
-- ════════════════════════════════════════════════════════════
-- 권한 (project_app: CRUD only)
-- ════════════════════════════════════════════════════════════
GRANT SELECT, INSERT, UPDATE, DELETE ON task_attachments, project_notifications TO project_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO project_app;
SELECT 'project_db 002 ready' AS status;
+17
View File
@@ -0,0 +1,17 @@
-- =====================================================================
-- project_db 마이그레이션 003 — 업무 시작/마감 시간(TIME) 추가
-- =====================================================================
-- 멱등. NULL = 시간 미지정(종일). DROP 없음.
--
-- 실행:
-- docker exec -i postgres-db psql -U postgres -d project_db \
-- < scripts/sql/project_db_003_task_times.sql
-- =====================================================================
\set ON_ERROR_STOP on
\connect project_db
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS start_time TIME;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS due_time TIME;
SELECT 'project_db 003 ready' AS status;
+185
View File
@@ -0,0 +1,185 @@
-- =====================================================================
-- project_db 초기화 스크립트 (PostgreSQL) — 프로젝트 관리(아사나식) 모듈
-- =====================================================================
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
--
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
--
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
--
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
-- read -s -p "project_app password: " APP_PWD; echo
-- docker exec -i postgres-db psql -U postgres \
-- -v app_password="$APP_PWD" \
-- < scripts/sql/project_db_init.sql
--
-- 2) main-app .env 에 연결 정보 등록
-- PROJECT_DB_URL=postgresql://project_app:<APP_PWD>@postgres-db:5432/project_db
--
-- 3) main-app 재기동
-- cd /opt/www/main && docker compose up -d --build
--
-- 주의:
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
-- =====================================================================
\set ON_ERROR_STOP on
-- DB 가 없을 때만 생성
SELECT 'CREATE DATABASE project_db ENCODING ''UTF8'' TEMPLATE template0'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'project_db')
\gexec
-- 앱 전용 로그인 역할 (vacation_app / cupang_app 패턴과 동일)
SELECT 'CREATE ROLE project_app LOGIN PASSWORD ' || quote_literal(:'app_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'project_app')
\gexec
-- 항상 최신 비밀번호로 동기화
SELECT 'ALTER ROLE project_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
\gexec
GRANT CONNECT ON DATABASE project_db TO project_app;
-- project_db 컨텍스트로 전환
\connect project_db
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
CREATE OR REPLACE FUNCTION project_set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ════════════════════════════════════════════════════════════
-- 1) 프로젝트 / 서브프로젝트 (self-referential: parent_id)
-- parent_id NULL = 최상위 프로젝트, NOT NULL = 서브프로젝트.
-- 삭제는 CASCADE (서브프로젝트·단계·업무 모두 함께 제거).
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS projects (
id BIGSERIAL PRIMARY KEY,
parent_id BIGINT REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '#4573d2',
owner_email TEXT NOT NULL DEFAULT '',
start_date DATE,
due_date DATE,
status TEXT NOT NULL DEFAULT 'active', -- active | archived
sort_order INTEGER NOT NULL DEFAULT 0,
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_projects_parent ON projects (parent_id);
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects (status);
DROP TRIGGER IF EXISTS trg_projects_updated ON projects;
CREATE TRIGGER trg_projects_updated
BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION project_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 2) 프로젝트 멤버 배정 (관리자가 사용자를 프로젝트에 지정)
-- role: manager(서브프로젝트/업무 관리) | member
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS project_members (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_email TEXT NOT NULL,
user_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'member', -- manager | member
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (project_id, user_email)
);
CREATE INDEX IF NOT EXISTS idx_project_members_project ON project_members (project_id);
CREATE INDEX IF NOT EXISTS idx_project_members_email ON project_members (user_email);
-- ════════════════════════════════════════════════════════════
-- 3) 진행 단계 (칸반 컬럼) — 프로젝트별. 생성 시 기본 4단계 seed.
-- is_done_stage: 이 단계로 옮기면 업무 '완료'로 간주(메일 트리거).
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS project_stages (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_done_stage BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_project_stages_project ON project_stages (project_id, sort_order);
-- ════════════════════════════════════════════════════════════
-- 4) 업무 (task)
-- stage_id 로 진행단계 표현. assignee_email 로 담당자 지정.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS tasks (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
stage_id BIGINT REFERENCES project_stages(id) ON DELETE SET NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
assignee_email TEXT NOT NULL DEFAULT '',
assignee_name TEXT NOT NULL DEFAULT '',
priority TEXT NOT NULL DEFAULT 'normal', -- low | normal | high
start_date DATE,
due_date DATE,
completed_at TIMESTAMPTZ,
sort_order INTEGER NOT NULL DEFAULT 0,
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks (project_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_tasks_stage ON tasks (stage_id);
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks (assignee_email);
CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks (due_date);
DROP TRIGGER IF EXISTS trg_tasks_updated ON tasks;
CREATE TRIGGER trg_tasks_updated
BEFORE UPDATE ON tasks
FOR EACH ROW EXECUTE FUNCTION project_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 5) 업무 댓글 (아사나 기본기능 — 스켈레톤 단계 테이블)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS task_comments (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
author_email TEXT NOT NULL DEFAULT '',
author_name TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments (task_id, created_at);
-- ════════════════════════════════════════════════════════════
-- 6) 활동 이력 (배정/완료 등 — 메일 알림 근거 + 타임라인)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS project_activity (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE,
task_id BIGINT REFERENCES tasks(id) ON DELETE CASCADE,
actor_email TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL, -- assigned | completed | created | stage_changed ...
detail TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_project_activity_project ON project_activity (project_id, created_at DESC);
-- ════════════════════════════════════════════════════════════
-- 7) 권한 (project_app: CRUD only)
-- ════════════════════════════════════════════════════════════
GRANT USAGE ON SCHEMA public TO project_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON
projects, project_members, project_stages, tasks, task_comments, project_activity
TO project_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO project_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO project_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO project_app;
SELECT 'project_db ready' AS status;
+242
View File
@@ -0,0 +1,242 @@
"""말레이시아 재고관리 — 순수 로직 테스트 (DB 불필요).
실행:
cd /opt/www/main (또는 개발 PC 프로젝트 루트)
python -m pytest tests/test_malaysia_stock.py -v
pytest 미설치 시:
python tests/test_malaysia_stock.py # __main__ 폴백으로 assert 실행
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
import pytest
except ModuleNotFoundError: # pytest 미설치 → 최소 shim 으로 standalone 실행 지원
class _PytestShim:
class mark:
@staticmethod
def parametrize(argnames, argvalues):
def deco(fn):
fn._params = (argnames, list(argvalues))
return fn
return deco
class raises:
def __init__(self, exc):
self.exc = exc
def __enter__(self):
return self
def __exit__(self, etype, evalue, tb):
if etype is None:
raise AssertionError(f"{self.exc.__name__} 가 발생하지 않음")
return issubclass(etype, self.exc)
pytest = _PytestShim() # type: ignore
from app.modules.malaysia import store
# ────────────────────────────────────────────────────────────
# 코드 분류
# ────────────────────────────────────────────────────────────
def test_classify_code():
assert store.classify_code("MT-0320") == "individual"
assert store.classify_code("MX-0001") == "individual"
assert store.classify_code("MZ-0002") == "individual"
assert store.classify_code("MY-0002") == "set"
assert store.classify_code("MD-0000") == "blocked"
assert store.classify_code("ZZ-9999") == "unknown"
# 소문자/공백 정규화
assert store.classify_code(" mt-0320 ") == "individual"
# ────────────────────────────────────────────────────────────
# movement 코드 검증 — MT/MX/MZ 허용, MY/MD 차단
# ────────────────────────────────────────────────────────────
def test_validate_movement_code_ok():
assert store.validate_movement_code("mt-0320") == "MT-0320"
@pytest.mark.parametrize("bad", ["MY-0002", "MD-0000", "ZZ-1", ""])
def test_validate_movement_code_blocks(bad):
with pytest.raises(ValueError):
store.validate_movement_code(bad)
# ────────────────────────────────────────────────────────────
# 재고조사 코드 검증 — MT/MX/MZ/MY 허용, MD 차단
# ────────────────────────────────────────────────────────────
def test_validate_stocktake_code():
assert store.validate_stocktake_code("MY-0002") == "MY-0002"
assert store.validate_stocktake_code("MT-0320") == "MT-0320"
with pytest.raises(ValueError):
store.validate_stocktake_code("MD-0001")
# ────────────────────────────────────────────────────────────
# set_bom 검증 — set 은 MY-, component 는 MT/MX/MZ (중첩/MD 금지)
# ────────────────────────────────────────────────────────────
def test_validate_bom():
assert store.validate_bom_set_code("MY-0001") == "MY-0001"
assert store.validate_bom_component_code("MT-0320") == "MT-0320"
with pytest.raises(ValueError):
store.validate_bom_set_code("MT-0320") # set 자리에 낱개
with pytest.raises(ValueError):
store.validate_bom_component_code("MY-0002") # 세트 중첩 금지
with pytest.raises(ValueError):
store.validate_bom_component_code("MD-0000") # 뚜껑 금지
def test_validate_qty():
assert store.validate_qty(5) == 5
assert store.validate_qty(0) == 0
assert store.validate_qty(-3, allow_negative=True) == -3
with pytest.raises(ValueError):
store.validate_qty(-1)
with pytest.raises(ValueError):
store.validate_qty(0, allow_zero=False)
# ────────────────────────────────────────────────────────────
# 세트 분해
# ────────────────────────────────────────────────────────────
def _bom_map():
# MY-0002 Starter Set = MT-0320 x1 + MT-0550 x2
return {
"MY-0002": [
{"component_code": "MT-0320", "component_qty": 1},
{"component_code": "MT-0550", "component_qty": 2},
],
"MY-0003": [
{"component_code": "MT-0320", "component_qty": 1},
],
}
def test_explode_set():
out = store.explode_set("MY-0002", 20, _bom_map())
assert out == {"MT-0320": 20, "MT-0550": 40}
def test_explode_set_missing_returns_empty():
assert store.explode_set("MY-9999", 5, _bom_map()) == {}
# ────────────────────────────────────────────────────────────
# 재고조사 집계 — 요구사항 예시 검증
# MT-0320 낱개 100 + MY-0002 20세트(MT-0320 1개 포함) → 120
# ────────────────────────────────────────────────────────────
def test_explode_stocktake_example():
lines = [
{"sku_code": "MT-0320", "qty": 100},
{"sku_code": "MY-0002", "qty": 20},
]
res = store.explode_stocktake(lines, _bom_map())
assert res["MT-0320"]["direct_qty"] == 100
assert res["MT-0320"]["from_set_qty"] == 20 # 20세트 x 1
assert res["MT-0320"]["total_qty"] == 120
# MT-0550 은 낱개 직접 입력 없음, 세트에서만 40
assert res["MT-0550"]["direct_qty"] == 0
assert res["MT-0550"]["from_set_qty"] == 40
assert res["MT-0550"]["total_qty"] == 40
def test_explode_stocktake_multiple_sets_same_component():
# MY-0002(MT-0320 x1) 10세트 + MY-0003(MT-0320 x1) 5세트 → from_set 15
lines = [
{"sku_code": "MT-0320", "qty": 0},
{"sku_code": "MY-0002", "qty": 10},
{"sku_code": "MY-0003", "qty": 5},
]
res = store.explode_stocktake(lines, _bom_map())
assert res["MT-0320"]["from_set_qty"] == 15
assert res["MT-0320"]["total_qty"] == 15
def test_missing_bom_sets():
lines = [
{"sku_code": "MY-0002", "qty": 1}, # BOM 있음
{"sku_code": "MY-0005", "qty": 1}, # BOM 없음 → 경고
]
missing = store.missing_bom_sets(lines, _bom_map())
assert missing == ["MY-0005"]
# ────────────────────────────────────────────────────────────
# 랙 입력 — 레이아웃 / 집계
# ────────────────────────────────────────────────────────────
def test_rack_cell_codes_count_and_format():
codes = store.rack_cell_codes()
# A/B 2면 × 3행 × 3열 × 2분할 = 36칸
assert len(codes) == 36
assert len(set(codes)) == 36
assert "A3-1-1" in codes
assert "B1-3-2" in codes
# 그림과 동일: 첫 칸은 A면 3행 1열 1분할
assert codes[0] == "A3-1-1"
def test_rack_layout_structure():
layout = store.rack_layout()
assert [s["side"] for s in layout] == ["A", "B"]
side_a = layout[0]
assert [r["row"] for r in side_a["rows"]] == [3, 2, 1]
# 각 행 3열, 각 열 2분할
first_row = side_a["rows"][0]
assert len(first_row["cols"]) == 3
assert first_row["cols"][0] == ["A3-1-1", "A3-1-2"]
def test_aggregate_rack_sums_units_times_boxes():
entries = [
{"sku_code": "MT-0320", "units_per_box": 24, "box_count": 3}, # 72
{"sku_code": "MT-0320", "units_per_box": 24, "box_count": 1}, # +24 (다른 칸)
{"sku_code": "MY-0002", "units_per_box": 6, "box_count": 2}, # 12 (콤보도 집계)
]
out = store.aggregate_rack(entries)
assert out == {"MT-0320": 96, "MY-0002": 12}
def test_aggregate_rack_skips_incomplete():
entries = [
{"sku_code": "", "units_per_box": 10, "box_count": 2}, # 코드 없음
{"sku_code": "MT-0550", "units_per_box": 0, "box_count": 5}, # 입수량 0
{"sku_code": "MT-0750", "units_per_box": 5, "box_count": 0}, # 박스 0
{"sku_code": "MT-0950", "units_per_box": "x", "box_count": 2}, # 정수 아님
]
assert store.aggregate_rack(entries) == {}
# pytest 미설치 환경 폴백
if __name__ == "__main__":
import traceback
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
total = 0
failed = 0
for fn in fns:
params = getattr(fn, "_params", None)
cases: list[tuple] = [()]
if params:
argnames, argvalues = params
cases = [(v if isinstance(v, tuple) else (v,)) for v in argvalues]
for case in cases:
total += 1
label = f"{fn.__name__}{case if case else ''}"
try:
fn(*case)
print(f"PASS: {label}")
except Exception: # noqa: BLE001
failed += 1
print(f"FAIL: {label}")
traceback.print_exc()
print(f"\n{total} tests, {failed} failed")
sys.exit(1 if failed else 0)