Compare commits
254 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31eab0d4cb | |||
| 6d5c3f3181 | |||
| b440c0eed0 | |||
| 77ce86f5ed | |||
| 718b5e6d8f | |||
| 6dc14a7350 | |||
| 26d0753579 | |||
| 85dffb0178 | |||
| b74d2be6d3 | |||
| 0822baf5b7 | |||
| 6e0180944e | |||
| 7c5a62ecc1 | |||
| e4fb945474 | |||
| e817dd75d5 | |||
| f8f4cded66 | |||
| 523b9b3150 | |||
| c0ca3cf64f | |||
| 92ee239904 | |||
| 17f2d56231 | |||
| e36fcb1a34 | |||
| d89aa349eb | |||
| 88ce4ee378 | |||
| 7c85c147e0 | |||
| 133bfc55b8 | |||
| a6adb5fc41 | |||
| 03916a61b1 | |||
| a22bb1e043 | |||
| 64dd7445bc | |||
| 57b4b37468 | |||
| 105e036573 | |||
| 7a85367db8 | |||
| 5f85826965 | |||
| 15eabd0bbf | |||
| 776f655db4 | |||
| ef7f76821d | |||
| 4008eee088 | |||
| 8ed1263bef | |||
| d60ae9a210 | |||
| 78f212fffd | |||
| c192d61c71 | |||
| 8d7fe01c2d | |||
| c4abafd883 | |||
| d3b06e1695 | |||
| 2ab38c7dbd | |||
| b583627bd3 | |||
| 27e2c17606 | |||
| 9221efe925 | |||
| 2c7f98ef30 | |||
| 0204b418e6 | |||
| 1d6a523c1f | |||
| cdc3e903d7 | |||
| 425556aea8 | |||
| 1d3b97c8fc | |||
| 90524100ed | |||
| 46d23a3323 | |||
| a1dd9bed4a | |||
| ef4658fb1d | |||
| 9c7f88a09d | |||
| d9acd86573 | |||
| cee0c65a01 | |||
| 95561270a9 | |||
| 163537fd96 | |||
| 106cf70922 | |||
| 691eecee52 | |||
| f1097b85dc | |||
| c0a661db82 | |||
| 5426889f91 | |||
| 8523a620ad | |||
| ec037d7045 | |||
| 6141f0e38a | |||
| fe8a4f146e | |||
| 4a07ef139d | |||
| 1c14c748dd | |||
| bd79d20eb1 | |||
| ec8c1caced | |||
| 31eaac8424 | |||
| dfe79878b1 | |||
| fd4ef9180c | |||
| a65ca65a8b | |||
| a189b93427 | |||
| 99eea01fc1 | |||
| 8453c425f8 | |||
| a3a1d3fbad | |||
| 1424772afa | |||
| b0925b86a1 | |||
| fb114a222c | |||
| 0ce932f0e2 | |||
| 650a14fe7c | |||
| ddb06e42db | |||
| aedb5f1fdb | |||
| 2bbe36ccf0 | |||
| ec9630144d | |||
| 5c43cb801c | |||
| 55a2fc8793 | |||
| fa396bd237 | |||
| a683c38b76 | |||
| 6a2eded9b5 | |||
| fb473f74ee | |||
| a807238cba | |||
| cb3b6841e1 | |||
| 81352d1ca7 | |||
| d2701c2e22 | |||
| 411c03e352 | |||
| 5b9d9c45ea | |||
| c786fbabd7 | |||
| 765d3ba985 | |||
| 03a96b6118 | |||
| f383297fc5 | |||
| e256c6c5ee | |||
| 6763b2af68 | |||
| 2c0eeb5502 | |||
| 6e32c68590 | |||
| 4d05c7fad5 | |||
| c91059b415 | |||
| b970542482 | |||
| 438f309178 | |||
| eb6ee77f72 | |||
| f4133ad61d | |||
| 9377cd2f5a | |||
| 536e5a35f1 | |||
| eacde5b322 | |||
| b24a418718 | |||
| f1eac5eda2 | |||
| 1e0e69fcbe | |||
| 4c7eb9467f | |||
| d92da186f3 | |||
| 032fb78867 | |||
| 65997adfd3 | |||
| 8f882ff4cd | |||
| 84d266ea89 | |||
| 56cff672e2 | |||
| d56365b338 | |||
| 03929ff517 | |||
| b18efcd814 | |||
| 3139e213a7 | |||
| b312884ddc | |||
| 6f4a62013a | |||
| c07d3301e4 | |||
| 3d85ccf43e | |||
| ea03a7aa18 | |||
| 9039d1d80b | |||
| 300f4e23c8 | |||
| 4fdb5c181e | |||
| 3b3f9739fe | |||
| 6b43d9926d | |||
| f137f2a6a6 | |||
| 13ac11cc0a | |||
| 069d998c9d | |||
| e782cb4ea7 | |||
| 5582c72467 | |||
| b678d7c37f | |||
| 5b0fe2f1a1 | |||
| 6ae0d90b97 | |||
| 09e2f06a14 | |||
| a107b06826 | |||
| 506db5ff07 | |||
| 0b04a05b26 | |||
| ec8c9edcc9 | |||
| 7ff3500844 | |||
| 84624baf8e | |||
| d728c55e2a | |||
| 55c98b6b95 | |||
| ca53042eb4 | |||
| e2c6a50b46 | |||
| cb43fe1257 | |||
| 8f3761aeae | |||
| d30dcbada4 | |||
| 9f361c4e6a | |||
| fde2c9c2e9 | |||
| 162e1d22a9 | |||
| 1806c75ecb | |||
| 8ab5223790 | |||
| fa1027fa1b | |||
| 32fc12e283 | |||
| 8562c4f65d | |||
| bec9c5e5a6 | |||
| 8c6539d6cb | |||
| 269e779129 | |||
| 3cf22513ca | |||
| 33ce9482dd | |||
| ce1014e6bc | |||
| 652e53c3e8 | |||
| 5c7730d4a1 | |||
| 57f0c3426f | |||
| bcbf3de24c | |||
| 2f16457ac6 | |||
| fdabeec4e3 | |||
| f25948185b | |||
| 7d39c2edec | |||
| 90e286dc4d | |||
| 2e3ea610b7 | |||
| c11c5733de | |||
| 478cde8caf | |||
| 574c053077 | |||
| 800fbd1da4 | |||
| 2607e0610f | |||
| 150335cf87 | |||
| d4e823f1fa | |||
| b75fda5901 | |||
| d9ea7550b0 | |||
| 4113a034f4 | |||
| f9c1dc140b | |||
| 17dfd23cfc | |||
| 45f6b3b174 | |||
| 867bc510da | |||
| 69b84db4bb | |||
| 1d33761003 | |||
| 96e2c49506 | |||
| 34c7e2f964 | |||
| 9e707be5ac | |||
| 6918426264 | |||
| 151de492ad | |||
| d4af2dc064 | |||
| d2c1dfa2a0 | |||
| a4eaf9b770 | |||
| 57ea186061 | |||
| 004b2dff1f | |||
| 550867ba0c | |||
| afacc9a7db | |||
| 9c58b022e2 | |||
| d7b6c7d029 | |||
| 316e3fe8d1 | |||
| 64c77b36ea | |||
| 927990c002 | |||
| 9cf889d824 | |||
| dcecc1476e | |||
| 36c43012d9 | |||
| 9a8dace7ec | |||
| c16bc905ed | |||
| 98ac452e7d | |||
| 844c796578 | |||
| 7ddc765326 | |||
| c274e50ee0 | |||
| e33cc8686b | |||
| 023ce80d59 | |||
| e8a85be36c | |||
| 0c007e6432 | |||
| b070fabb96 | |||
| bad5d04d8b | |||
| 6c82415e02 | |||
| cd545d723e | |||
| a52b66868d | |||
| f93eac9ce5 | |||
| a85322da58 | |||
| c4ae1ab3e6 | |||
| 672a99ef80 | |||
| d2b145576b | |||
| e1af7285a1 | |||
| 6993ab9397 | |||
| 280ed02790 | |||
| a128207762 | |||
| 7f37402561 | |||
| 6203917ac0 | |||
| 21de32a568 |
@@ -1,7 +1,74 @@
|
|||||||
GOOGLE_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
|
GOOGLE_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
|
||||||
GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret
|
GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret
|
||||||
|
# OMS(orderlist) 와 SSO 하려면 양쪽 .env 에 동일한 값으로 설정.
|
||||||
|
# 미설정 시 컨테이너 기동 실패(fail-fast). openssl rand -hex 32 로 생성 권장.
|
||||||
SESSION_SECRET_KEY=replace-with-a-long-random-secret
|
SESSION_SECRET_KEY=replace-with-a-long-random-secret
|
||||||
|
SESSION_COOKIE_NAME=session
|
||||||
SESSION_COOKIE_SECURE=true
|
SESSION_COOKIE_SECURE=true
|
||||||
|
# 세션 유효기간(초). OMS(orderlist) 와 동일하게 맞출 것. 28800=8h
|
||||||
|
SESSION_MAX_AGE=28800
|
||||||
|
|
||||||
|
# 포털 접근 허용 이메일. 쉼표로 구분. 비워두면 코드의 기본 목록 사용.
|
||||||
|
ALLOWED_EMAILS=king@dbxcorp.co.kr,julie@dbxcorp.co.kr,ellen@dbxcorp.co.kr,bj@dbxcorp.co.kr
|
||||||
PUBLIC_BASE_URL=https://dbx.no1king.freeddns.org
|
PUBLIC_BASE_URL=https://dbx.no1king.freeddns.org
|
||||||
CS_ORDER_URL=https://cs.example.com
|
# 비워두면 같은 도메인의 /corm/ 경로(NPM 리버스 프록시)로 자동 연결됨
|
||||||
CUSTOMER_ORDER_LIST_URL=https://orders.example.com
|
CS_ORDER_URL=/corm/
|
||||||
|
# 비워두면 같은 도메인의 /orderlist/ 경로(NPM 리버스 프록시)로 자동 연결됨
|
||||||
|
CUSTOMER_ORDER_LIST_URL=/orderlist/
|
||||||
|
|
||||||
|
# ─── 개인경비 모듈 (expense_db) ───
|
||||||
|
# 설정하면 PostgreSQL 사용, 미설정 시 DATA_DIR/expense.json 사용.
|
||||||
|
# DB/역할 생성: scripts/sql/expense_db_init.sql 참고.
|
||||||
|
# EXPENSE_DB_URL=postgresql://expense_app:replace-me@postgres-db:5432/expense_db
|
||||||
|
|
||||||
|
# ─── 쿠팡 밀크런 모듈 (cupang_db) ───
|
||||||
|
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
|
||||||
|
# DB/역할/스키마/센터 seed 생성: scripts/sql/cupang_db_init.sql 참고.
|
||||||
|
# CUPANG_DB_URL=postgresql://cupang_app:replace-me@postgres-db:5432/cupang_db
|
||||||
|
|
||||||
|
# ─── 휴가 관리 모듈 (vacation_db) ───
|
||||||
|
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
|
||||||
|
# DB/역할/스키마/공휴일 seed 생성: scripts/sql/vacation_db_init.sql 참고.
|
||||||
|
# 권한키: vacation(접근) / vacation_approver(승인·반려). admin 은 항상 통과.
|
||||||
|
# VACATION_DB_URL=postgresql://vacation_app:replace-me@postgres-db:5432/vacation_db
|
||||||
|
|
||||||
|
# ─── 말레이시아 창고 재고관리 모듈 (malaysia_stock_db) ───
|
||||||
|
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
|
||||||
|
# DB/역할/스키마/창고·아이템 seed 생성: scripts/sql/malaysia_stock_db_init.sql 참고.
|
||||||
|
# 권한키: malaysia(접근). admin 은 항상 통과. 상품명은 아래 ITEMCODE_DB_URL 재사용.
|
||||||
|
# MALAYSIA_STOCK_DB_URL=postgresql://malaysia_app:replace-me@postgres-db:5432/malaysia_stock_db
|
||||||
|
|
||||||
|
# ─── 말레이시아 배송 모듈 (dispatch_db) — TikTok 출고관리 ───
|
||||||
|
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
|
||||||
|
# DB/역할/스키마 생성: scripts/sql/dispatch_db_init.sql 참고.
|
||||||
|
# 권한키: dispatch(접근). admin 은 항상 통과.
|
||||||
|
# 업로드 원본 파일은 DATA_DIR/dispatch/<날짜>/<배치>/ 아래에 저장된다.
|
||||||
|
# 개인정보(고객 이름/주소/전화)는 저장하지 않는다.
|
||||||
|
# DISPATCH_DB_URL=postgresql://dispatch_app:replace-me@postgres-db:5432/dispatch_db
|
||||||
|
|
||||||
|
# ─── 프로젝트 관리 모듈 (project_db) — 아사나식 ───
|
||||||
|
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
|
||||||
|
# DB/역할/스키마 생성: scripts/sql/project_db_init.sql 참고.
|
||||||
|
# 진입 권한: 로그인한 회사 직원 전원(별도 권한키 없음). 프로젝트 생성/배정은 관리자만.
|
||||||
|
# PROJECT_DB_URL=postgresql://project_app:replace-me@postgres-db:5432/project_db
|
||||||
|
|
||||||
|
# ─── 메일 알림 (SMTP) — 프로젝트 업무 배정/완료 시 관리자 알림 ───
|
||||||
|
# SMTP_HOST 가 있어야 발송. 미설정 시 조용히 skip(앱은 정상 동작).
|
||||||
|
# SMTP_TLS: true(STARTTLS,587 기본) | ssl(SMTPS,465) | false(평문)
|
||||||
|
# SMTP_HOST=smtp.gmail.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=alarm@dbxcorp.co.kr
|
||||||
|
# SMTP_PASSWORD=app-password-here
|
||||||
|
# SMTP_FROM=alarm@dbxcorp.co.kr
|
||||||
|
# SMTP_TLS=true
|
||||||
|
# 알림 수신자(쉼표구분). 비워두면 ERP 관리자(admin) 전원에게 발송.
|
||||||
|
# PROJECT_NOTIFY_EMAIL=king@dbxcorp.co.kr
|
||||||
|
|
||||||
|
# ─── 상품 검색 (itemcode_db 읽기 전용) ───
|
||||||
|
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
|
||||||
|
# 미설정 시 검색 비활성 → 수동 등록만 가능.
|
||||||
|
# itemcode_db 실제 테이블: single_items(낱개) / set_items(세트)
|
||||||
|
# 컬럼: item_code, sabangnet_code, name
|
||||||
|
# 읽기 전용 역할 itemcode_ro 를 먼저 생성하고(아래 DSN), 낱개+세트 UNION 검색 SQL 사용:
|
||||||
|
# ITEMCODE_DB_URL=postgresql://itemcode_ro:replace-me@postgres-db:5432/itemcode_db
|
||||||
|
# ITEMCODE_SEARCH_SQL=SELECT item_code AS code, name AS name, '낱개' AS type FROM single_items WHERE item_code ILIKE %(q)s OR name ILIKE %(q)s UNION ALL SELECT item_code AS code, name AS name, '세트' AS type FROM set_items WHERE item_code ILIKE %(q)s OR name ILIKE %(q)s ORDER BY code ASC LIMIT %(limit)s
|
||||||
|
|||||||
@@ -2,12 +2,24 @@
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
|
# 런타임 데이터 (사용자 이메일/경비 등 PII — 커밋 금지. 운영은 DATA_DIR 볼륨)
|
||||||
|
app/data/
|
||||||
|
|
||||||
|
# 로컬 전용 스크립트 (자격증명 포함 가능)
|
||||||
|
push-gitea.bat
|
||||||
|
run-local.bat
|
||||||
|
run-local.ps1
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.venv/
|
.venv/
|
||||||
|
|
||||||
# IDE
|
# IDE / 에디터
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
|
||||||
|
# dispatch 라벨 PDF 샘플(실제 고객 PII — 커밋 금지)
|
||||||
|
docs/samples/
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# main-app ERP 프로젝트 작업 기준
|
||||||
|
|
||||||
|
> 이 문서는 Claude Code가 이 저장소에서 작업할 때 가장 먼저 확인하는 기준 문서입니다.
|
||||||
|
> 작업 시작 전, 아래 "반드시 먼저 읽을 문서"를 모두 확인한 뒤 작업을 시작합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 반드시 먼저 읽을 문서
|
||||||
|
|
||||||
|
Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아래 문서를 순서대로 읽고 맥락을 확보**한 뒤 작업한다.
|
||||||
|
|
||||||
|
1. `docs/PROJECT_OVERVIEW.md` — 프로젝트 정의, 기능 범위, 연동 대상
|
||||||
|
2. `docs/SERVER_ARCHITECTURE.md` — 서버 구성도와 네트워크 흐름
|
||||||
|
3. `docs/DATABASES.md` — PostgreSQL DB 구성과 명명 규칙
|
||||||
|
4. `docs/DEPLOYMENT.md` — 배포 경로, 서비스 실행 방식, 복구 절차
|
||||||
|
|
||||||
|
문서 간 내용이 충돌하면 위의 우선순위(1 → 4)를 따른다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프로젝트 한 줄 정의
|
||||||
|
|
||||||
|
`main-app`은 DBX ERP 시스템의 **메인 프로젝트(허브)** 이다.
|
||||||
|
|
||||||
|
담당 영역:
|
||||||
|
|
||||||
|
- 주문관리
|
||||||
|
- 상품코드 매칭
|
||||||
|
- 재고관리
|
||||||
|
- CS관리
|
||||||
|
- 반품관리
|
||||||
|
- 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등)
|
||||||
|
- 개인경비 (`app/modules/expense/`, `expense_db`)
|
||||||
|
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용
|
||||||
|
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
|
||||||
|
- 말레이시아 창고 재고관리 (`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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개발 원칙
|
||||||
|
|
||||||
|
- 기존 코드를 수정하기 전 관련 파일을 먼저 읽고 구조를 파악한다.
|
||||||
|
- 위험 명령은 **반드시 사용자 확인 후** 실행한다 (아래 "위험 명령" 절 참고).
|
||||||
|
- `.env`, API 키, DB 비밀번호, OAuth Secret, 토큰은 절대 Git에 올리지 않는다.
|
||||||
|
- 신규 DB가 필요하면 **승인 요청 후** 생성하며, DB명은 반드시 `_db`로 끝낸다 (예: `inventory_db`).
|
||||||
|
- 예전 문서/코드의 `orderlist_app`은 현재 기준 `orderlist_db`이다. 발견 시 수정 대상.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 위험 명령 (사용자 확인 없이 실행 금지)
|
||||||
|
|
||||||
|
아래 명령은 **반드시 사용자에게 의도를 설명하고 명시적 승인을 받은 뒤** 실행한다.
|
||||||
|
|
||||||
|
| 분류 | 명령 예시 |
|
||||||
|
| --- | --- |
|
||||||
|
| 파일 삭제 | `rm -rf`, `Remove-Item -Recurse -Force` |
|
||||||
|
| DB 파괴 | `DROP DATABASE`, `DROP TABLE`, `DROP SCHEMA` |
|
||||||
|
| 데이터 삭제 | `TRUNCATE`, 조건 없는 대량 `DELETE`, `UPDATE` |
|
||||||
|
| Docker 파괴 | `docker volume rm`, `docker volume prune`, `docker system prune -a --volumes` |
|
||||||
|
| Git 파괴 | `git reset --hard`, `git push --force`, `git clean -fd`, `git branch -D` |
|
||||||
|
| 운영 초기화 | 운영 DB 덤프 덮어쓰기, 마이그레이션 롤백 |
|
||||||
|
|
||||||
|
원칙:
|
||||||
|
|
||||||
|
1. 실행 전 현재 상태 확인 명령을 먼저 보여준다 (예: `docker ps`, `\l`, `git status`).
|
||||||
|
2. 백업 존재 여부와 위치를 명시한다.
|
||||||
|
3. 실행 후 결과 확인 절차를 같이 제시한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 서버 작업 원칙
|
||||||
|
|
||||||
|
- 배포, DB 복구, Docker 작업 전에는 현재 상태 확인 명령을 먼저 제안한다.
|
||||||
|
- PostgreSQL 작업 전에는 DB명, 컨테이너명, 포트, 백업 위치를 확인한다.
|
||||||
|
- 운영 서버 경로와 개발 PC 경로를 혼동하지 않는다.
|
||||||
|
- 개발 PC: `G:\내 드라이브\프로젝트\Main-app`
|
||||||
|
- **운영 서버 (main-app): `/opt/www/main`** ← 본 프로젝트 경로
|
||||||
|
- 참고용 (같은 호스트 내 다른 서비스 경로): `/opt/dbx-corm`, `/opt/dbx-orderlist`
|
||||||
|
- 명령 예시·문서 작성 시 main-app 경로는 **반드시 `/opt/www/main`** 사용. 상세는 `docs/DEPLOYMENT.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 환경 변수 / 비밀값
|
||||||
|
|
||||||
|
- `.env`, `.env.local`, `.env.production` 은 **Git에 절대 커밋하지 않는다**.
|
||||||
|
- 예시 파일(`*.example`)만 커밋한다.
|
||||||
|
- 비밀값 유출이 의심되면 즉시 회전(rotate)을 권고한다.
|
||||||
|
- 신규 환경변수 추가 시 `*.example` 파일과 본 문서(또는 `docs/DEPLOYMENT.md`)에 변수 설명을 함께 갱신한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 디렉터리 구조 (요약)
|
||||||
|
|
||||||
|
```
|
||||||
|
Main-app/
|
||||||
|
├─ app/ FastAPI 앱 소스
|
||||||
|
├─ docs/ 운영/설계 문서 (작업 전 필독)
|
||||||
|
├─ scripts/ 배포·유지보수 스크립트
|
||||||
|
├─ skills/ Claude Code 규칙/스킬
|
||||||
|
├─ docker-compose.yml 운영 컴포즈
|
||||||
|
├─ docker-compose.local.yml 로컬 컴포즈
|
||||||
|
├─ Dockerfile
|
||||||
|
├─ requirements.txt
|
||||||
|
└─ CLAUDE.md ← 이 문서
|
||||||
|
```
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# Ui — Style Reference
|
||||||
|
> Monochromatic architectural blueprint – precise, functional forms on a stark, bright canvas.
|
||||||
|
|
||||||
|
**Theme:** light
|
||||||
|
|
||||||
|
This design system feels like a finely tuned machine, presenting a clean and precise interface with a stark black-and-white aesthetic. The visual mood is serious and functional, achieved through a dominant achromatic palette and very subtle elevation. Geometric balance is created by mixing hard 10-14px radii for cards and inputs with highly rounded (near-pill) buttons and badges, suggesting both structure and approachability. The use of a custom sans-serif font across all elements with meticulous letter-spacing creates a unified, crisp typographic voice.
|
||||||
|
|
||||||
|
## Tokens — Colors
|
||||||
|
|
||||||
|
| Name | Value | Token | Role |
|
||||||
|
|------|-------|-------|------|
|
||||||
|
| Canvas White | `#ffffff` | `--color-canvas-white` | Page background, primary card surfaces, popovers. The foundational bright base. |
|
||||||
|
| Ghost Gray | `#f2f2f2` | `--color-ghost-gray` | Secondary background for segmented sections or subtle card differentiation. Lighter than default background. |
|
||||||
|
| Subtle Ash | `#e5e5e5` | `--color-subtle-ash` | Border colors for inputs, cards, and dividers. Provides definition without harshness. |
|
||||||
|
| Midtone Gray | `#737373` | `--color-midtone-gray` | Muted text, placeholder text in inputs, secondary icons. Recedes into the background. |
|
||||||
|
| Rich Black | `#0a0a0a` | `--color-rich-black` | Primary text color for body copy, standard icons, badges with white text. High contrast for readability. |
|
||||||
|
| Deep Black | `#000000` | `--color-deep-black` | Headings, active state button backgrounds, highlighted text. The darkest tone for strong emphasis. |
|
||||||
|
| Callout Red | `#c22b10` | `--color-callout-red` | Destructive actions, error states. A muted, serious red. |
|
||||||
|
| Success Green | `#10c22b` | `--color-success-green` | Success states, positive confirmations. A muted, serious green. |
|
||||||
|
|
||||||
|
## Tokens — Typography
|
||||||
|
|
||||||
|
### Geist — Primary brand font for all UI text, headings, and body. Its varied weights and precise tracking create a modern, technical feel. · `--font-geist`
|
||||||
|
- **Substitute:** Inter
|
||||||
|
- **Weights:** 400, 500, 600
|
||||||
|
- **Sizes:** 12px, 13px, 14px, 16px, 18px, 48px
|
||||||
|
- **Line height:** 1.00, 1.10, 1.20, 1.33, 1.38, 1.43, 1.50, 1.56, 1.63, 2.00
|
||||||
|
- **Letter spacing:** -0.0500em at 48px, -0.0250em at 18px
|
||||||
|
- **Role:** Primary brand font for all UI text, headings, and body. Its varied weights and precise tracking create a modern, technical feel.
|
||||||
|
|
||||||
|
### Geist Mono — Used for code snippets or specific input fields requiring monospaced characters. Reinforces a technical aesthetic. · `--font-geist-mono`
|
||||||
|
- **Substitute:** IBM Plex Mono
|
||||||
|
- **Weights:** 400
|
||||||
|
- **Sizes:** 14px
|
||||||
|
- **Line height:** 1.43
|
||||||
|
- **Letter spacing:** normal
|
||||||
|
- **Role:** Used for code snippets or specific input fields requiring monospaced characters. Reinforces a technical aesthetic.
|
||||||
|
|
||||||
|
### Type Scale
|
||||||
|
|
||||||
|
| Role | Size | Line Height | Letter Spacing | Token |
|
||||||
|
|------|------|-------------|----------------|-------|
|
||||||
|
| caption | 12px | 1.5 | — | `--text-caption` |
|
||||||
|
| body | 14px | 1.43 | — | `--text-body` |
|
||||||
|
| heading | 18px | 1.33 | -0.45px | `--text-heading` |
|
||||||
|
| display | 48px | 1 | -2.4px | `--text-display` |
|
||||||
|
|
||||||
|
## Tokens — Spacing & Shapes
|
||||||
|
|
||||||
|
**Density:** compact
|
||||||
|
|
||||||
|
### Spacing Scale
|
||||||
|
|
||||||
|
| Name | Value | Token |
|
||||||
|
|------|-------|-------|
|
||||||
|
| 4 | 4px | `--spacing-4` |
|
||||||
|
| 5 | 5px | `--spacing-5` |
|
||||||
|
| 6 | 6px | `--spacing-6` |
|
||||||
|
| 8 | 8px | `--spacing-8` |
|
||||||
|
| 10 | 10px | `--spacing-10` |
|
||||||
|
| 12 | 12px | `--spacing-12` |
|
||||||
|
| 16 | 16px | `--spacing-16` |
|
||||||
|
| 20 | 20px | `--spacing-20` |
|
||||||
|
| 24 | 24px | `--spacing-24` |
|
||||||
|
| 32 | 32px | `--spacing-32` |
|
||||||
|
| 40 | 40px | `--spacing-40` |
|
||||||
|
| 80 | 80px | `--spacing-80` |
|
||||||
|
| 83 | 83px | `--spacing-83` |
|
||||||
|
|
||||||
|
### Border Radius
|
||||||
|
|
||||||
|
| Element | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| pill | 9999px |
|
||||||
|
| badge | 26px |
|
||||||
|
| cards | 14px |
|
||||||
|
| input | 10px |
|
||||||
|
| buttons | 10px |
|
||||||
|
| default | 10px |
|
||||||
|
|
||||||
|
### Shadows
|
||||||
|
|
||||||
|
| Name | Value | Token |
|
||||||
|
|------|-------|-------|
|
||||||
|
| subtle | `lab(100 0 0) 0px 0px 0px 2px` | `--shadow-subtle` |
|
||||||
|
| subtle-2 | `oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0...` | `--shadow-subtle-2` |
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
- **Section gap:** 83px
|
||||||
|
- **Card padding:** 16px
|
||||||
|
- **Element gap:** 8px
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Primary Action Button
|
||||||
|
**Role:** Call to action.
|
||||||
|
|
||||||
|
Solid Deep Black (#000000) background with Canvas White (#ffffff) text. Features a 10px border-radius, 8px vertical padding, and 48px horizontal padding, making it a prominent rectangular element.
|
||||||
|
|
||||||
|
### Ghost Button
|
||||||
|
**Role:** Secondary or tertiary actions, often within groups.
|
||||||
|
|
||||||
|
Transparent background with Rich Black (#0a0a0a) text. Uses a 9999px border-radius for a pill shape, with no explicit padding defined by variants, implying content-based sizing.
|
||||||
|
|
||||||
|
### Split Button Left
|
||||||
|
**Role:** Left segment of a grouped button control.
|
||||||
|
|
||||||
|
Canvas White (#ffffff) background with Deep Black (#000000) text. Features a 10px border-radius on the left, 0px on the right, and 10px horizontal padding. Borders in Subtle Ash (#e5e5e5).
|
||||||
|
|
||||||
|
### Split Button Right
|
||||||
|
**Role:** Right segment of a grouped button control.
|
||||||
|
|
||||||
|
Canvas White (#ffffff) background with Deep Black (#000000) text. Features a 10px border-radius on the right, 0px on the left. Borders in Subtle Ash (#e5e5e5).
|
||||||
|
|
||||||
|
### Elevated Card
|
||||||
|
**Role:** Containers for distinct content blocks, forms, or data.
|
||||||
|
|
||||||
|
Canvas White (#ffffff) background with a 14px border-radius. Features a subtle shadow: oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px, providing minimal elevation. Inner content padding is 16px.
|
||||||
|
|
||||||
|
### Plain Input Field
|
||||||
|
**Role:** Standard text input.
|
||||||
|
|
||||||
|
Transparent background with Rich Black (#0a0a0a) text. Defined by a 1px Subtle Ash (#e5e5e5) border and a 10px border-radius. Inner padding is 4px vertical, 10px horizontal.
|
||||||
|
|
||||||
|
### Segmented Input Left
|
||||||
|
**Role:** Left segment of a grouped input control.
|
||||||
|
|
||||||
|
Transparent background with Rich Black (#0a0a0a) text. Features a 10px border-radius on the left and 0px on the right. Defined by a 1px Subtle Ash (#e5e5e5) border. Inner padding is 4px vertical, 10px horizontal.
|
||||||
|
|
||||||
|
### Inverse Tag Badge
|
||||||
|
**Role:** Highlighting status or category, with high contrast.
|
||||||
|
|
||||||
|
Deep Black (#171717) background with Canvas White (#ffffff) text. Features a 26px border-radius, creating a pill shape. Padding is 2px vertical, 8px horizontal.
|
||||||
|
|
||||||
|
### Neutral Tag Badge
|
||||||
|
**Role:** Subtle categorization or status.
|
||||||
|
|
||||||
|
Ghost Gray (#f2f2f2) background with Rich Black (#0a0a0a) text. Features a 26px border-radius, creating a pill shape. Padding is 2px vertical, 8px horizontal.
|
||||||
|
|
||||||
|
### Outline Tag Badge
|
||||||
|
**Role:** Very subtle categorization or option.
|
||||||
|
|
||||||
|
Transparent background with Rich Black (#0a0a0a) text. Features a 26px border-radius and a Light Ash (#a1a1a1) border. Padding is 2px vertical, 8px horizontal.
|
||||||
|
|
||||||
|
## Do's and Don'ts
|
||||||
|
|
||||||
|
### Do
|
||||||
|
- Use Deep Black (#000000) for primary headings and active states to command attention.
|
||||||
|
- Apply Subtle Ash (#e5e5e5) for all primary borders and dividers to maintain a subtle visual separation.
|
||||||
|
- Ensure input fields and cards consistently use a 10px or 14px border-radius, respectively, for geometric stability.
|
||||||
|
- Employ Geist font universally, leveraging its 400, 500, and 600 weights to establish clear hierarchy without introducing new typefaces.
|
||||||
|
- Maintain a default element gap of 8px, but use 16px for card inner padding to create adequate breathing room for content.
|
||||||
|
- Utilize 9999px or 26px border-radius for all interactive buttons and badges to create a soft, approachable pill shape.
|
||||||
|
|
||||||
|
### Don't
|
||||||
|
- Avoid using highly saturated colors; stick to the achromatic scale and the two semantic reds and greens.
|
||||||
|
- Do not introduce additional font families; the current choices are sufficient for all typographic needs.
|
||||||
|
- Refrain from using strong, multi-directional shadows; rely on minimal 1px shadows or simple borders for elevation.
|
||||||
|
- Do not deviate from the established border-radius values; the mix of sharp 0px (in split elements), 10px, 14px, and 9999px is intentional.
|
||||||
|
- Don't add excessive padding or margin; the design favors a compact density with specific, calculated spacing.
|
||||||
|
- Avoid decorative gradients; the brand's aesthetic is built on flat colors and subtle depth.
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
| Level | Name | Value | Purpose |
|
||||||
|
|-------|------|-------|---------|
|
||||||
|
| 0 | Canvas White | `#ffffff` | Primary page background and base surface for most content. |
|
||||||
|
| 1 | Elevated Card | `#ffffff` | Content cards and distinct sections that require a subtle lift, defined by borders or minimal shadow. |
|
||||||
|
| 2 | Search/Input Field | `#ffffff` | Interactive elements like search bars and inputs, often bordered. |
|
||||||
|
| 3 | Popovers/Overlays | `#ffffff` | Transient UI elements that appear above other content. |
|
||||||
|
| 4 | Ghost Gray Background | `#f2f2f2` | Used as a background color for secondary buttons or badges, indicating a slightly lower hierarchy. |
|
||||||
|
|
||||||
|
## Elevation
|
||||||
|
|
||||||
|
- **Elevated Card:** `oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px`
|
||||||
|
- **Focus Ring:** `lab(100 0 0) 0px 0px 0px 2px`
|
||||||
|
|
||||||
|
## Imagery
|
||||||
|
|
||||||
|
The visual language is purely utilitarian and functional. No photography or complex illustrations are present. Icons are monochromatic, typically black stroke or fill on white backgrounds, aligning with the stark aesthetic. Product components are presented directly, with an emphasis on UI elements rather than lifestyle or marketing visuals. Imagery's role is explanatory (via icons) or for showcasing UI components, maintaining a text-dominant layout. There are no decorative visuals.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
The page maintains a centered, contained layout with a maximum visible width, creating a focused content area. The hero section features a prominent, centered headline and subtext over the Canvas White background, followed by centrally aligned CTA buttons. Sections below are arranged in a multi-column grid, showcasing various UI components (forms, cards, controls). The rhythm is consistent vertical spacing, creating an organized, information-dense display. Navigation is a sticky top-bar with compact links and utility actions.
|
||||||
|
|
||||||
|
## Agent Prompt Guide
|
||||||
|
|
||||||
|
### Quick Color Reference
|
||||||
|
- **Text Primary:** #0a0a0a
|
||||||
|
- **Text Muted:** #737373
|
||||||
|
- **Background:** #ffffff
|
||||||
|
- **CTA Background:** #000000
|
||||||
|
- **Border:** #e5e5e5
|
||||||
|
- **Accent (Semantic Red):** #c22b10
|
||||||
|
|
||||||
|
### Example Component Prompts
|
||||||
|
1. **Create a Hero Section:** Canvas White (#ffffff) background. Headline 'The Foundation for your Design System' in Geist weight 600, 48px, line-height 1.0, letter-spacing -2.4px, color Deep Black (#000000). Subtext 'A set of beautifully designed components that you can customize...' in Geist weight 400, 18px, line-height 1.33, letter-spacing -0.45px, color Rich Black (#0a0a0a). Below this, a Primary Action Button labeled 'New Project' and a Ghost Button labeled 'View Components'. Section gap 83px.
|
||||||
|
2. **Generate an Elevated Card:** Canvas White (#ffffff) background, 14px border-radius, with shadow 'oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px'. Inside, use 16px internal padding. Title 'Payment Method' in Geist weight 500, 16px, Rich Black (#0a0a0a). Body text 'All transactions are secure...' in Geist weight 400, 14px, Midtone Gray (#737373). Include a Plain Input Field with label 'Name on Card'.
|
||||||
|
3. **Design a Form Input Group:** Two segmented input fields. The first is a Segmented Input Left for 'Card Number', with placeholder '1234 5678 9012 3456'. The second is a Plain Input Field for 'CVV', placeholder '123'. Both bordered with Subtle Ash (#e5e5e5).
|
||||||
|
4. **Create a Navigation Bar:** Canvas White (#ffffff) background. Left aligned: 'Docs', 'Components', 'Blocks', 'Charts', 'Directory', 'Create' as text links in Geist weight 400, 14px, Rich Black (#0a0a0a). Right aligned: a Plain Input Field 'Search documentation...' and a Primary Action Button labeled '+ New'. Elements within the nav should have 8px element gap between them.
|
||||||
|
|
||||||
|
## Similar Brands
|
||||||
|
|
||||||
|
- **Vercel** — Dominant use of a stark black-and-white achromatic palette, clean typography, and focus on developer tools and component showcasing.
|
||||||
|
- **Linear** — Systematic grid-based UI, minimal use of color, and high-fidelity, component-driven interaction patterns.
|
||||||
|
- **Figma** — Functional, dark-mode leaning interfaces with strong typography and precise spacing, emphasizing tool-like utility.
|
||||||
|
- **Revolut (early UI)** — Modern, crisp UI with strong geometric shapes, restrained use of color for status, and emphasis on clear data presentation.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### CSS Custom Properties
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Colors */
|
||||||
|
--color-canvas-white: #ffffff;
|
||||||
|
--color-ghost-gray: #f2f2f2;
|
||||||
|
--color-subtle-ash: #e5e5e5;
|
||||||
|
--color-midtone-gray: #737373;
|
||||||
|
--color-rich-black: #0a0a0a;
|
||||||
|
--color-deep-black: #000000;
|
||||||
|
--color-callout-red: #c22b10;
|
||||||
|
--color-success-green: #10c22b;
|
||||||
|
|
||||||
|
/* Typography — Font Families */
|
||||||
|
--font-geist: 'Geist', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-geist-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
|
||||||
|
/* Typography — Scale */
|
||||||
|
--text-caption: 12px;
|
||||||
|
--leading-caption: 1.5;
|
||||||
|
--text-body: 14px;
|
||||||
|
--leading-body: 1.43;
|
||||||
|
--text-heading: 18px;
|
||||||
|
--leading-heading: 1.33;
|
||||||
|
--tracking-heading: -0.45px;
|
||||||
|
--text-display: 48px;
|
||||||
|
--leading-display: 1;
|
||||||
|
--tracking-display: -2.4px;
|
||||||
|
|
||||||
|
/* Typography — Weights */
|
||||||
|
--font-weight-regular: 400;
|
||||||
|
--font-weight-medium: 500;
|
||||||
|
--font-weight-semibold: 600;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-4: 4px;
|
||||||
|
--spacing-5: 5px;
|
||||||
|
--spacing-6: 6px;
|
||||||
|
--spacing-8: 8px;
|
||||||
|
--spacing-10: 10px;
|
||||||
|
--spacing-12: 12px;
|
||||||
|
--spacing-16: 16px;
|
||||||
|
--spacing-20: 20px;
|
||||||
|
--spacing-24: 24px;
|
||||||
|
--spacing-32: 32px;
|
||||||
|
--spacing-40: 40px;
|
||||||
|
--spacing-80: 80px;
|
||||||
|
--spacing-83: 83px;
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
--section-gap: 83px;
|
||||||
|
--card-padding: 16px;
|
||||||
|
--element-gap: 8px;
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--radius-md: 4px;
|
||||||
|
--radius-lg: 10px;
|
||||||
|
--radius-xl: 14px;
|
||||||
|
--radius-3xl: 26px;
|
||||||
|
--radius-full: 9996px;
|
||||||
|
--radius-full-2: 9999px;
|
||||||
|
--radius-full-3: 159981px;
|
||||||
|
--radius-full-4: 159984px;
|
||||||
|
|
||||||
|
/* Named Radii */
|
||||||
|
--radius-pill: 9999px;
|
||||||
|
--radius-badge: 26px;
|
||||||
|
--radius-cards: 14px;
|
||||||
|
--radius-input: 10px;
|
||||||
|
--radius-buttons: 10px;
|
||||||
|
--radius-default: 10px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-subtle: lab(100 0 0) 0px 0px 0px 2px;
|
||||||
|
--shadow-subtle-2: oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px;
|
||||||
|
|
||||||
|
/* Surfaces */
|
||||||
|
--surface-canvas-white: #ffffff;
|
||||||
|
--surface-elevated-card: #ffffff;
|
||||||
|
--surface-searchinput-field: #ffffff;
|
||||||
|
--surface-popoversoverlays: #ffffff;
|
||||||
|
--surface-ghost-gray-background: #f2f2f2;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tailwind v4
|
||||||
|
|
||||||
|
```css
|
||||||
|
@theme {
|
||||||
|
/* Colors */
|
||||||
|
--color-canvas-white: #ffffff;
|
||||||
|
--color-ghost-gray: #f2f2f2;
|
||||||
|
--color-subtle-ash: #e5e5e5;
|
||||||
|
--color-midtone-gray: #737373;
|
||||||
|
--color-rich-black: #0a0a0a;
|
||||||
|
--color-deep-black: #000000;
|
||||||
|
--color-callout-red: #c22b10;
|
||||||
|
--color-success-green: #10c22b;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-geist: 'Geist', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-geist-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
|
||||||
|
/* Typography — Scale */
|
||||||
|
--text-caption: 12px;
|
||||||
|
--leading-caption: 1.5;
|
||||||
|
--text-body: 14px;
|
||||||
|
--leading-body: 1.43;
|
||||||
|
--text-heading: 18px;
|
||||||
|
--leading-heading: 1.33;
|
||||||
|
--tracking-heading: -0.45px;
|
||||||
|
--text-display: 48px;
|
||||||
|
--leading-display: 1;
|
||||||
|
--tracking-display: -2.4px;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-4: 4px;
|
||||||
|
--spacing-5: 5px;
|
||||||
|
--spacing-6: 6px;
|
||||||
|
--spacing-8: 8px;
|
||||||
|
--spacing-10: 10px;
|
||||||
|
--spacing-12: 12px;
|
||||||
|
--spacing-16: 16px;
|
||||||
|
--spacing-20: 20px;
|
||||||
|
--spacing-24: 24px;
|
||||||
|
--spacing-32: 32px;
|
||||||
|
--spacing-40: 40px;
|
||||||
|
--spacing-80: 80px;
|
||||||
|
--spacing-83: 83px;
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--radius-md: 4px;
|
||||||
|
--radius-lg: 10px;
|
||||||
|
--radius-xl: 14px;
|
||||||
|
--radius-3xl: 26px;
|
||||||
|
--radius-full: 9996px;
|
||||||
|
--radius-full-2: 9999px;
|
||||||
|
--radius-full-3: 159981px;
|
||||||
|
--radius-full-4: 159984px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-subtle: lab(100 0 0) 0px 0px 0px 2px;
|
||||||
|
--shadow-subtle-2: oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px;
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -5,27 +5,86 @@ from typing import Any
|
|||||||
|
|
||||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||||
|
from pydantic import BaseModel
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
from .modules.cupang import build_cupang_store, build_itemcode_reader
|
||||||
|
from .modules.cupang import router as cupang_router
|
||||||
|
from .modules.dispatch import build_dispatch_store
|
||||||
|
from .modules.dispatch import router as dispatch_router
|
||||||
|
from .modules.expense import CategoryStore, build_expense_store
|
||||||
|
from .modules.expense import router as expense_router
|
||||||
|
from .modules.malaysia import build_malaysia_itemcode, build_malaysia_store
|
||||||
|
from .modules.malaysia import router as malaysia_router
|
||||||
|
from .modules.project import build_project_store
|
||||||
|
from .modules.project import router as project_router
|
||||||
|
from .modules.vacation import build_vacation_store
|
||||||
|
from .modules.vacation import router as vacation_router
|
||||||
|
from .store import (
|
||||||
|
APPROVER_KEYS,
|
||||||
|
MODULE_KEYS,
|
||||||
|
SUPER_ADMIN_EMAIL,
|
||||||
|
UserStore,
|
||||||
|
allowed_modules,
|
||||||
|
has_module,
|
||||||
|
is_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 권한 키 한글 라벨 (admin.html / 사이드바 공용)
|
||||||
|
MODULE_LABELS: dict[str, str] = {
|
||||||
|
"corm": "CORM",
|
||||||
|
"order": "Order",
|
||||||
|
"expense": "개인경비",
|
||||||
|
"vacation": "휴가",
|
||||||
|
"cupang": "쿠팡 밀크런",
|
||||||
|
"malaysia": "말레이시아 재고관리",
|
||||||
|
"dispatch": "말레이시아 배송",
|
||||||
|
"project": "프로젝트 관리",
|
||||||
|
"expense_approver": "개인경비",
|
||||||
|
"vacation_approver": "휴가",
|
||||||
|
}
|
||||||
|
|
||||||
|
# OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름.
|
||||||
|
SESSION_COOKIE_DEFAULT = "session"
|
||||||
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
ALLOWED_DOMAIN = "dbxcorp.co.kr"
|
ALLOWED_DOMAIN = "dbxcorp.co.kr"
|
||||||
ALLOWED_EMAILS = {
|
|
||||||
"king@dbxcorp.co.kr",
|
|
||||||
"julie@dbxcorp.co.kr",
|
def _data_dir() -> Path:
|
||||||
"ellen@dbxcorp.co.kr",
|
"""사용자/권한 JSON 저장소 위치. 컨테이너 재배포에도 살아남도록
|
||||||
"bj@dbxcorp.co.kr",
|
DATA_DIR 환경변수로 마운트된 볼륨을 가리킬 수 있다."""
|
||||||
}
|
override = os.getenv("DATA_DIR", "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
return BASE_DIR / "data"
|
||||||
|
|
||||||
|
|
||||||
|
DATA_DIR = _data_dir()
|
||||||
|
|
||||||
|
|
||||||
def env(name: str, default: str = "") -> str:
|
def env(name: str, default: str = "") -> str:
|
||||||
return os.getenv(name, default).strip()
|
value = os.getenv(name, "").strip()
|
||||||
|
return value if value else default
|
||||||
|
|
||||||
|
|
||||||
|
def _require_session_secret() -> str:
|
||||||
|
secret = env("SESSION_SECRET_KEY", "")
|
||||||
|
if not secret:
|
||||||
|
raise RuntimeError(
|
||||||
|
"SESSION_SECRET_KEY 환경변수가 설정되지 않았습니다. "
|
||||||
|
"openssl rand -hex 32 로 새 값을 만들어 .env 에 넣고 컨테이너를 재기동하세요. "
|
||||||
|
"OMS(orderlist) 와 SSO 하려면 양쪽 .env 에 같은 값이어야 합니다."
|
||||||
|
)
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
def build_google_oauth() -> OAuth:
|
def build_google_oauth() -> OAuth:
|
||||||
@@ -43,15 +102,82 @@ def build_google_oauth() -> OAuth:
|
|||||||
app = FastAPI(title="DBX 메인 페이지")
|
app = FastAPI(title="DBX 메인 페이지")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
SessionMiddleware,
|
SessionMiddleware,
|
||||||
secret_key=env("SESSION_SECRET_KEY", "change-this-session-secret"),
|
secret_key=_require_session_secret(),
|
||||||
|
session_cookie=env("SESSION_COOKIE_NAME", SESSION_COOKIE_DEFAULT),
|
||||||
https_only=env("SESSION_COOKIE_SECURE", "true").lower() == "true",
|
https_only=env("SESSION_COOKIE_SECURE", "true").lower() == "true",
|
||||||
same_site="lax",
|
same_site="lax",
|
||||||
max_age=60 * 60 * 8,
|
max_age=int(env("SESSION_MAX_AGE", "28800")),
|
||||||
|
path="/",
|
||||||
)
|
)
|
||||||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||||
|
|
||||||
|
# 모듈별 templates 디렉토리를 추가로 검색하도록 ChoiceLoader 설정.
|
||||||
|
# 신규 모듈 추가 시 아래 리스트에 `BASE_DIR / "modules" / "<name>" / "templates"` 만 추가.
|
||||||
|
_MODULE_TEMPLATE_DIRS = [
|
||||||
|
BASE_DIR / "modules" / "expense" / "templates",
|
||||||
|
BASE_DIR / "modules" / "cupang" / "templates",
|
||||||
|
BASE_DIR / "modules" / "vacation" / "templates",
|
||||||
|
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(
|
||||||
|
[
|
||||||
|
FileSystemLoader(str(BASE_DIR / "templates")),
|
||||||
|
*[FileSystemLoader(str(p)) for p in _MODULE_TEMPLATE_DIRS if p.exists()],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
# 모듈별 데이터 저장소.
|
||||||
|
# EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일.
|
||||||
|
app.state.data_dir = DATA_DIR # 모듈에서 첨부 저장 경로 등으로 참조
|
||||||
|
app.state.expense_store = build_expense_store(
|
||||||
|
dsn=env("EXPENSE_DB_URL") or None,
|
||||||
|
json_path=DATA_DIR / "expense.json",
|
||||||
|
)
|
||||||
|
# 분류(category) 설정 — 관리자가 추가/삭제, 저장 즉시 반영. 항상 JSON 파일.
|
||||||
|
app.state.expense_category_store = CategoryStore(DATA_DIR / "expense_categories.json")
|
||||||
|
# 쿠팡 밀크런: CUPANG_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||||
|
# 상품 검색은 itemcode_db 읽기 전용(미설정 시 수동 입력 폴백).
|
||||||
|
app.state.cupang_store = build_cupang_store(dsn=env("CUPANG_DB_URL") or None)
|
||||||
|
app.state.itemcode_reader = build_itemcode_reader()
|
||||||
|
# 휴가 관리: VACATION_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||||
|
app.state.vacation_store = build_vacation_store(dsn=env("VACATION_DB_URL") or None)
|
||||||
|
# 말레이시아 재고관리: MALAYSIA_STOCK_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||||
|
# 상품명은 위 itemcode_reader(읽기 전용) 재사용.
|
||||||
|
app.state.malaysia_store = build_malaysia_store(dsn=env("MALAYSIA_STOCK_DB_URL") or None)
|
||||||
|
# 세트 BOM 은 itemcode_db(set_components)에서 읽는다(ITEMCODE_DB_URL 재사용).
|
||||||
|
app.state.malaysia_itemcode = build_malaysia_itemcode()
|
||||||
|
# 말레이시아 배송(TikTok 출고): DISPATCH_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||||
|
# 업로드 원본은 DATA_DIR/dispatch/ 아래 저장(개인정보는 저장하지 않음).
|
||||||
|
app.state.dispatch_store = build_dispatch_store(dsn=env("DISPATCH_DB_URL") or None)
|
||||||
|
# 프로젝트 관리(아사나식): PROJECT_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||||
|
# 메일 알림은 SMTP_* 환경변수 기반(app/mail.py). 미설정 시 조용히 skip.
|
||||||
|
app.state.project_store = build_project_store(dsn=env("PROJECT_DB_URL") or None)
|
||||||
|
|
||||||
|
# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄.
|
||||||
|
app.include_router(expense_router)
|
||||||
|
app.include_router(cupang_router)
|
||||||
|
app.include_router(vacation_router)
|
||||||
|
app.include_router(malaysia_router)
|
||||||
|
app.include_router(dispatch_router)
|
||||||
|
app.include_router(project_router)
|
||||||
|
|
||||||
|
|
||||||
def public_url_for(request: Request, route_name: str) -> str:
|
def public_url_for(request: Request, route_name: str) -> str:
|
||||||
@@ -61,9 +187,49 @@ def public_url_for(request: Request, route_name: str) -> str:
|
|||||||
return str(request.url_for(route_name))
|
return str(request.url_for(route_name))
|
||||||
|
|
||||||
|
|
||||||
def get_user(request: Request) -> dict[str, Any] | None:
|
def get_session_user(request: Request) -> dict[str, Any] | None:
|
||||||
|
"""세션에서 로그인 사용자(이메일/이름/사진) 추출. OMS SSO 호환."""
|
||||||
user = request.session.get("user")
|
user = request.session.get("user")
|
||||||
return user if isinstance(user, dict) else None
|
if isinstance(user, dict):
|
||||||
|
return user
|
||||||
|
email = request.session.get("user_email")
|
||||||
|
if email:
|
||||||
|
return {
|
||||||
|
"email": str(email),
|
||||||
|
"name": request.session.get("user_name") or str(email),
|
||||||
|
"picture": request.session.get("user_picture", "") or "",
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user_record(request: Request) -> dict[str, Any] | None:
|
||||||
|
"""세션 + 저장소를 합쳐 권한이 포함된 사용자 레코드 반환."""
|
||||||
|
sess = get_session_user(request)
|
||||||
|
if not sess:
|
||||||
|
return None
|
||||||
|
rec = user_store.get(sess["email"])
|
||||||
|
if rec is None:
|
||||||
|
# 세션은 살아있지만 저장소에 없음 — 세션 무효화
|
||||||
|
return None
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(request: Request) -> dict[str, Any]:
|
||||||
|
rec = get_current_user_record(request)
|
||||||
|
if rec is None:
|
||||||
|
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||||
|
if not is_admin(rec):
|
||||||
|
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def safe_next(raw: str | None) -> str:
|
||||||
|
"""Open redirect 방지: 같은 호스트의 절대 경로만 허용."""
|
||||||
|
if not raw:
|
||||||
|
return "/"
|
||||||
|
if not raw.startswith("/") or raw.startswith("//") or raw.startswith("/\\"):
|
||||||
|
return "/"
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
def render_template(
|
def render_template(
|
||||||
@@ -97,33 +263,215 @@ def is_allowed_google_user(userinfo: dict[str, Any]) -> tuple[bool, str]:
|
|||||||
return False, "Google 계정 이메일 인증이 확인되지 않았습니다."
|
return False, "Google 계정 이메일 인증이 확인되지 않았습니다."
|
||||||
if domain != ALLOWED_DOMAIN:
|
if domain != ALLOWED_DOMAIN:
|
||||||
return False, "회사 Google Workspace 계정만 접속할 수 있습니다."
|
return False, "회사 Google Workspace 계정만 접속할 수 있습니다."
|
||||||
if email not in ALLOWED_EMAILS:
|
|
||||||
return False, "접속 허용 목록에 없는 계정입니다."
|
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── ERP 메뉴 정의 ──────────────────────────────────────────────
|
||||||
|
# 각 항목: key(권한키), title, description, url(env override), status(ready|preparing), category
|
||||||
|
def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"key": "corm",
|
||||||
|
"title": "CORM",
|
||||||
|
"subtitle": "CS · 발주 · 반품 · 코드관리",
|
||||||
|
"description": "고객 응대와 발주/반품, 코드 관리 업무를 한 곳에서 처리합니다.",
|
||||||
|
"url": env("CS_ORDER_URL", "/corm/"),
|
||||||
|
"health_url": "/corm/health/db",
|
||||||
|
"status": "ready",
|
||||||
|
"category": "운영",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "order",
|
||||||
|
"title": "Order",
|
||||||
|
"subtitle": "고객 주문 데이터베이스",
|
||||||
|
"description": "고객 주문 내역을 조회·검색하고 관련 데이터를 관리합니다.",
|
||||||
|
"url": env("CUSTOMER_ORDER_LIST_URL", "/orderlist/"),
|
||||||
|
"health_url": "/orderlist/health/db",
|
||||||
|
"status": "ready",
|
||||||
|
"category": "운영",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "expense",
|
||||||
|
"title": "개인경비",
|
||||||
|
"subtitle": "Personal Expense",
|
||||||
|
"description": "법인카드/개인경비 사용 내역을 등록·증빙하고 정산을 신청합니다.",
|
||||||
|
"url": "/expense/",
|
||||||
|
"health_url": "/expense/health",
|
||||||
|
"status": "ready",
|
||||||
|
"category": "관리",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "cupang",
|
||||||
|
"title": "쿠팡 밀크런",
|
||||||
|
"subtitle": "Coupang Milk-run",
|
||||||
|
"description": "쿠팡 밀크런 출고 일정·박스 계산·입고센터를 달력에서 관리합니다.",
|
||||||
|
"url": "/cupang/",
|
||||||
|
"health_url": "/cupang/health",
|
||||||
|
"status": "ready",
|
||||||
|
"category": "운영",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vacation",
|
||||||
|
"title": "휴가",
|
||||||
|
"subtitle": "Vacation",
|
||||||
|
"description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 달력에서 관리합니다.",
|
||||||
|
"url": "/vacation/",
|
||||||
|
"health_url": "/vacation/health",
|
||||||
|
"status": "ready",
|
||||||
|
"category": "관리",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "malaysia",
|
||||||
|
"title": "말레이시아 재고관리",
|
||||||
|
"subtitle": "Malaysia Stock",
|
||||||
|
"description": "말레이시아 창고 입출고·세트 BOM·일일 재고조사를 관리합니다.",
|
||||||
|
"url": "/malaysia/",
|
||||||
|
"health_url": "/malaysia/health",
|
||||||
|
"status": "ready",
|
||||||
|
"category": "운영",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "dispatch",
|
||||||
|
"title": "말레이시아 배송",
|
||||||
|
"subtitle": "Malaysia Dispatch",
|
||||||
|
"description": "TikTok 출고 파일을 올리면 박스별 작업 리스트·피킹 요약·Kagayaku 전달표를 자동 생성합니다.",
|
||||||
|
"url": "/dispatch/",
|
||||||
|
"health_url": "/dispatch/health",
|
||||||
|
"status": "ready",
|
||||||
|
"category": "운영",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "project",
|
||||||
|
"title": "프로젝트 관리",
|
||||||
|
"subtitle": "Projects",
|
||||||
|
"description": "프로젝트·서브프로젝트·업무를 달력/타임라인/보드로 관리합니다(아사나식).",
|
||||||
|
"url": "/project/",
|
||||||
|
"health_url": "/project/health",
|
||||||
|
"status": "ready",
|
||||||
|
"category": "관리",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
allowed = allowed_modules(user_rec)
|
||||||
|
for item in items:
|
||||||
|
item["allowed"] = item["key"] in allowed
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _icon_svg(name: str) -> str:
|
||||||
|
"""좌측 사이드바용 인라인 아이콘. 외부 의존 없는 작은 SVG."""
|
||||||
|
paths = {
|
||||||
|
"home": '<path d="M3 11 12 3l9 8"/><path d="M5 10v10h14V10"/>',
|
||||||
|
"expense": '<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><path d="M7 15h4"/>',
|
||||||
|
"vacation": '<path d="M8 2v4"/><path d="M16 2v4"/><rect x="3" y="6" width="18" height="15" rx="2"/><path d="M3 11h18"/>',
|
||||||
|
"corm": '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z"/>',
|
||||||
|
"order": '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>',
|
||||||
|
"cupang": '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/>',
|
||||||
|
"malaysia": '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
|
||||||
|
"dispatch": '<rect x="1" y="3" width="15" height="13"/><path d="M16 8h4l3 3v5h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/>',
|
||||||
|
"project": '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="13" x2="13" y2="13"/><line x1="8" y1="16" x2="11" y2="16"/>',
|
||||||
|
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
|
||||||
|
}
|
||||||
|
body = paths.get(name, paths["modules"])
|
||||||
|
return (
|
||||||
|
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
|
||||||
|
'stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
|
||||||
|
f"{body}</svg>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_erp_nav(
|
||||||
|
user_rec: dict[str, Any], active: str | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""좌측 사이드바 메뉴. 모듈 정의(_menu_items_for)와 동기화한다.
|
||||||
|
|
||||||
|
각 항목: key, label, group, url, target, icon, active, disabled, disabled_reason.
|
||||||
|
"""
|
||||||
|
items: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"key": "home",
|
||||||
|
"label": "홈",
|
||||||
|
"group": "ERP",
|
||||||
|
"url": "/",
|
||||||
|
"target": "_self",
|
||||||
|
"icon": _icon_svg("home"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for m in _menu_items_for(user_rec):
|
||||||
|
if not m["allowed"]:
|
||||||
|
continue
|
||||||
|
target = "_blank" if m["url"].startswith("http") else "_self"
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"key": m["key"],
|
||||||
|
"label": m["title"],
|
||||||
|
"group": m["category"],
|
||||||
|
"url": m["url"] if m["status"] == "ready" else "#",
|
||||||
|
"target": target,
|
||||||
|
"icon": _icon_svg(m["key"]),
|
||||||
|
"disabled": m["status"] != "ready",
|
||||||
|
"disabled_reason": "준비중" if m["status"] != "ready" else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 같은 그룹끼리 묶이도록 정렬(그룹 헤더 중복 방지). 그룹 내 순서는 유지(stable).
|
||||||
|
group_order = {"ERP": 0, "운영": 1, "관리": 2}
|
||||||
|
items.sort(key=lambda it: group_order.get(it["group"], 9))
|
||||||
|
for it in items:
|
||||||
|
it["active"] = it["key"] == active
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def home(request: Request) -> HTMLResponse:
|
async def home(request: Request) -> HTMLResponse:
|
||||||
user = get_user(request)
|
sess = get_session_user(request)
|
||||||
if not user:
|
if not sess:
|
||||||
|
return render_template(request, "login.html")
|
||||||
|
user_rec = user_store.get(sess["email"])
|
||||||
|
if user_rec is None:
|
||||||
|
# 도메인은 통과했으나 저장소에 없음 — 세션 정리 후 재로그인
|
||||||
|
request.session.clear()
|
||||||
return render_template(request, "login.html")
|
return render_template(request, "login.html")
|
||||||
|
|
||||||
menu_items = [
|
# 슈퍼 관리자 → 업무 모듈 선택 화면(main.html).
|
||||||
{
|
# 일반 사용자 → ERP 메인(좌측 메뉴 + 우측 콘텐츠).
|
||||||
"title": "CS 발주 업무",
|
if user_rec.get("is_super_admin"):
|
||||||
"description": "CS 발주 업무 페이지로 이동",
|
menu_items = _menu_items_for(user_rec)
|
||||||
"url": env("CS_ORDER_URL", "#"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "고객 주문리스트 프로그램",
|
|
||||||
"description": "고객 주문리스트 프로그램으로 이동",
|
|
||||||
"url": env("CUSTOMER_ORDER_LIST_URL", "#"),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
return render_template(
|
return render_template(
|
||||||
request,
|
request,
|
||||||
"main.html",
|
"main.html",
|
||||||
{"user": user, "menu_items": menu_items},
|
{
|
||||||
|
"user": user_rec,
|
||||||
|
"menu_items": menu_items,
|
||||||
|
"is_admin": is_admin(user_rec),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"erp_home.html",
|
||||||
|
{
|
||||||
|
"user": user_rec,
|
||||||
|
"is_admin": is_admin(user_rec),
|
||||||
|
"nav_items": build_erp_nav(user_rec, active="home"),
|
||||||
|
"page_title": "ERP 홈",
|
||||||
|
"page_subtitle": "오늘의 업무를 시작하세요.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/modules", response_class=HTMLResponse)
|
||||||
|
async def modules_page(request: Request) -> HTMLResponse:
|
||||||
|
"""업무 모듈 선택 화면. 슈퍼 관리자가 사이드바에서 다시 진입할 때 사용."""
|
||||||
|
user_rec = get_current_user_record(request)
|
||||||
|
if user_rec is None:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"main.html",
|
||||||
|
{
|
||||||
|
"user": user_rec,
|
||||||
|
"menu_items": _menu_items_for(user_rec),
|
||||||
|
"is_admin": is_admin(user_rec),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -137,6 +485,8 @@ async def login(request: Request):
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
request.session["_post_login_next"] = safe_next(request.query_params.get("next"))
|
||||||
|
|
||||||
redirect_uri = public_url_for(request, "auth_google")
|
redirect_uri = public_url_for(request, "auth_google")
|
||||||
return await oauth.google.authorize_redirect(
|
return await oauth.google.authorize_redirect(
|
||||||
request,
|
request,
|
||||||
@@ -171,12 +521,24 @@ async def auth_google(request: Request):
|
|||||||
status_code=403,
|
status_code=403,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
email = str(userinfo.get("email", "")).lower().strip()
|
||||||
|
name = userinfo.get("name") or email
|
||||||
|
picture = userinfo.get("picture", "") or ""
|
||||||
|
|
||||||
|
# 저장소에 사용자 등록/갱신 (신규는 권한 0 — 관리자가 부여)
|
||||||
|
user_store.upsert_login(email=email, name=name, picture=picture)
|
||||||
|
|
||||||
|
# OMS 와 공유하는 top-level 키 (SSO 계약)
|
||||||
|
request.session["user_email"] = email
|
||||||
|
request.session["user_name"] = name
|
||||||
|
request.session["user_picture"] = picture
|
||||||
request.session["user"] = {
|
request.session["user"] = {
|
||||||
"email": str(userinfo.get("email", "")).lower().strip(),
|
"email": email,
|
||||||
"name": userinfo.get("name") or userinfo.get("email"),
|
"name": name,
|
||||||
"picture": userinfo.get("picture", ""),
|
"picture": picture,
|
||||||
}
|
}
|
||||||
return RedirectResponse(url="/", status_code=303)
|
next_url = safe_next(request.session.pop("_post_login_next", "/"))
|
||||||
|
return RedirectResponse(url=next_url, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/logout")
|
@app.get("/logout")
|
||||||
@@ -188,3 +550,98 @@ async def logout(request: Request) -> RedirectResponse:
|
|||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
async def healthz() -> dict[str, str]:
|
async def healthz() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 관리자 페이지 ──────────────────────────────────────────────
|
||||||
|
@app.get("/admin", response_class=HTMLResponse)
|
||||||
|
async def admin_page(request: Request) -> HTMLResponse:
|
||||||
|
rec = get_current_user_record(request)
|
||||||
|
if rec is None:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
if not is_admin(rec):
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"denied.html",
|
||||||
|
{"reason": "관리자만 접근할 수 있는 페이지입니다."},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
users = sorted(user_store.list_all(), key=lambda u: (u["email"] != SUPER_ADMIN_EMAIL, u["email"]))
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"admin.html",
|
||||||
|
{
|
||||||
|
"user": rec,
|
||||||
|
"users": users,
|
||||||
|
"module_keys": list(MODULE_KEYS),
|
||||||
|
"module_labels": MODULE_LABELS,
|
||||||
|
"approver_keys": list(APPROVER_KEYS),
|
||||||
|
"super_admin_email": SUPER_ADMIN_EMAIL,
|
||||||
|
"is_admin": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 관리자 API ──────────────────────────────────────────────
|
||||||
|
class UpdatePermissionsBody(BaseModel):
|
||||||
|
role: str | None = None
|
||||||
|
modules: dict[str, bool] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserBody(BaseModel):
|
||||||
|
email: str
|
||||||
|
name: str = ""
|
||||||
|
role: str = "user"
|
||||||
|
modules: dict[str, bool] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/users")
|
||||||
|
async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse:
|
||||||
|
return JSONResponse({"users": user_store.list_all()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/users")
|
||||||
|
async def api_create_user(
|
||||||
|
body: CreateUserBody,
|
||||||
|
_: dict[str, Any] = Depends(require_admin),
|
||||||
|
) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
rec = user_store.create_user(
|
||||||
|
email=body.email,
|
||||||
|
name=body.name,
|
||||||
|
role=body.role,
|
||||||
|
modules=body.modules,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return JSONResponse({"user": rec}, status_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/users/{email}")
|
||||||
|
async def api_update_user(
|
||||||
|
email: str,
|
||||||
|
body: UpdatePermissionsBody,
|
||||||
|
_: dict[str, Any] = Depends(require_admin),
|
||||||
|
) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
rec = user_store.update_permissions(
|
||||||
|
email=email, role=body.role, modules=body.modules
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc))
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return JSONResponse({"user": rec})
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/users/{email}")
|
||||||
|
async def api_delete_user(
|
||||||
|
email: str,
|
||||||
|
_: dict[str, Any] = Depends(require_admin),
|
||||||
|
) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
user_store.delete(email)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc))
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""쿠팡 밀크런(cupang) 모듈.
|
||||||
|
|
||||||
|
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
|
||||||
|
- 라우터: `router.py` (FastAPI APIRouter, prefix=/cupang)
|
||||||
|
- 저장소: `db.py` (cupang_db / PostgreSQL 전용) + `store.py` (상수/계산)
|
||||||
|
- 상품검색: `itemcode.py` (itemcode_db 읽기 전용)
|
||||||
|
- 템플릿: `templates/cupang/`
|
||||||
|
|
||||||
|
데이터 저장은 cupang_db 전용이다. CUPANG_DB_URL 미설정 시 build_cupang_store 는
|
||||||
|
None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를 보여준다(앱은 죽지 않음).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .router import router
|
||||||
|
from .store import DEFAULT_CENTERS, SHIP_METHODS, STATUSES, compute_boxes
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"router",
|
||||||
|
"STATUSES",
|
||||||
|
"SHIP_METHODS",
|
||||||
|
"DEFAULT_CENTERS",
|
||||||
|
"compute_boxes",
|
||||||
|
"build_cupang_store",
|
||||||
|
"build_itemcode_reader",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_cupang_store(*, dsn: str | None) -> Any:
|
||||||
|
"""CUPANG_DB_URL 이 있으면 CupangDBStore, 없으면 None.
|
||||||
|
|
||||||
|
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시.
|
||||||
|
"""
|
||||||
|
if not dsn:
|
||||||
|
return None
|
||||||
|
from .db import CupangDBStore # 지연 import (개발 환경 deps 없을 수 있음)
|
||||||
|
|
||||||
|
return CupangDBStore(dsn)
|
||||||
|
|
||||||
|
|
||||||
|
def build_itemcode_reader() -> Any:
|
||||||
|
"""itemcode_db 읽기 전용 상품 검색 리더. 설정 없으면 비활성(enabled=False)."""
|
||||||
|
from .itemcode import ItemcodeReader # 지연 import
|
||||||
|
|
||||||
|
return ItemcodeReader()
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
"""cupang_db PostgreSQL 저장소.
|
||||||
|
|
||||||
|
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — expense 모듈과 동일 패턴.
|
||||||
|
- 연결 정보: 환경변수 `CUPANG_DB_URL`
|
||||||
|
(예: postgresql://cupang_app:<pwd>@postgres-db:5432/cupang_db)
|
||||||
|
- 스키마(테이블/인덱스/트리거/seed)는 앱이 만들지 않는다.
|
||||||
|
`scripts/sql/cupang_db_init.sql` 을 superuser 가 사전 적용한다.
|
||||||
|
앱 계정(cupang_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다.
|
||||||
|
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
|
||||||
|
|
||||||
|
박스 수 계산은 서버에서 `store.compute_boxes` 로 재계산하여 저장한다.
|
||||||
|
클라이언트가 보낸 박스 수는 신뢰하지 않는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
|
from app.timezone import KST
|
||||||
|
|
||||||
|
from .store import STATUSES, compute_boxes
|
||||||
|
|
||||||
|
|
||||||
|
class CupangDBStore:
|
||||||
|
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||||
|
self._pool = ConnectionPool(
|
||||||
|
conninfo=dsn,
|
||||||
|
min_size=min_size,
|
||||||
|
max_size=max_size,
|
||||||
|
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||||
|
open=False,
|
||||||
|
)
|
||||||
|
self._pool.open(wait=False)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._pool.close()
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 입고센터 (cupang_centers)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def list_centers(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
|
||||||
|
where = "" if include_inactive else "WHERE active = TRUE"
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM cupang_centers {where} "
|
||||||
|
"ORDER BY active DESC, sort_order ASC, name ASC"
|
||||||
|
).fetchall()
|
||||||
|
return [self._center_serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def get_center(self, *, center_id: int) -> dict[str, Any] | None:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM cupang_centers WHERE id = %s", (center_id,)
|
||||||
|
).fetchone()
|
||||||
|
return self._center_serialize(row) if row else None
|
||||||
|
|
||||||
|
def create_center(self, *, name: str, sort_order: int = 0) -> dict[str, Any]:
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("센터명 필수")
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cupang_centers (name, sort_order)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT (name) DO UPDATE
|
||||||
|
SET active = TRUE, sort_order = EXCLUDED.sort_order
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(name, sort_order),
|
||||||
|
).fetchone()
|
||||||
|
return self._center_serialize(row)
|
||||||
|
|
||||||
|
def update_center(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
center_id: int,
|
||||||
|
name: str | None = None,
|
||||||
|
active: bool | None = None,
|
||||||
|
sort_order: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
sets: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if name is not None:
|
||||||
|
n = name.strip()
|
||||||
|
if not n:
|
||||||
|
raise ValueError("센터명은 비울 수 없습니다.")
|
||||||
|
sets.append("name = %s")
|
||||||
|
params.append(n)
|
||||||
|
if active is not None:
|
||||||
|
sets.append("active = %s")
|
||||||
|
params.append(bool(active))
|
||||||
|
if sort_order is not None:
|
||||||
|
sets.append("sort_order = %s")
|
||||||
|
params.append(int(sort_order))
|
||||||
|
if not sets:
|
||||||
|
raise ValueError("변경할 값이 없습니다.")
|
||||||
|
params.append(center_id)
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
f"UPDATE cupang_centers SET {', '.join(sets)} "
|
||||||
|
"WHERE id = %s RETURNING *",
|
||||||
|
params,
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(center_id)
|
||||||
|
return self._center_serialize(row)
|
||||||
|
|
||||||
|
def center_in_use(self, *, center_id: int) -> bool:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT 1 FROM cupang_shipments WHERE center_id = %s LIMIT 1",
|
||||||
|
(center_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
def delete_center(self, *, center_id: int) -> dict[str, Any]:
|
||||||
|
"""사용 중이면 hard delete 하지 않고 active=false 로 비활성화한다.
|
||||||
|
|
||||||
|
반환: {"deleted": bool, "deactivated": bool}
|
||||||
|
"""
|
||||||
|
if self.center_in_use(center_id=center_id):
|
||||||
|
self.update_center(center_id=center_id, active=False)
|
||||||
|
return {"deleted": False, "deactivated": True}
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM cupang_centers WHERE id = %s", (center_id,)
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise KeyError(center_id)
|
||||||
|
return {"deleted": True, "deactivated": False}
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 박스 입수량 규칙 (cupang_box_rules)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def list_box_rules(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
|
||||||
|
where = "" if include_inactive else "WHERE active = TRUE"
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM cupang_box_rules {where} "
|
||||||
|
"ORDER BY product_code ASC"
|
||||||
|
).fetchall()
|
||||||
|
return [self._rule_serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def upsert_box_rule(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
product_code: str,
|
||||||
|
units_per_box: int,
|
||||||
|
product_name_snapshot: str = "",
|
||||||
|
box_name: str = "쿠팡박스",
|
||||||
|
memo: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
code = (product_code or "").strip()
|
||||||
|
if not code:
|
||||||
|
raise ValueError("제품코드 필수")
|
||||||
|
upb = int(units_per_box)
|
||||||
|
if upb <= 0:
|
||||||
|
raise ValueError("박스당 입수량은 1 이상이어야 합니다.")
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cupang_box_rules
|
||||||
|
(product_code, product_name_snapshot, box_name, units_per_box, memo)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (product_code) DO UPDATE
|
||||||
|
SET product_name_snapshot = EXCLUDED.product_name_snapshot,
|
||||||
|
box_name = EXCLUDED.box_name,
|
||||||
|
units_per_box = EXCLUDED.units_per_box,
|
||||||
|
memo = EXCLUDED.memo,
|
||||||
|
active = TRUE
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(code, product_name_snapshot.strip(), (box_name or "쿠팡박스").strip(), upb, memo.strip()),
|
||||||
|
).fetchone()
|
||||||
|
return self._rule_serialize(row)
|
||||||
|
|
||||||
|
def delete_box_rule(self, *, rule_id: int) -> None:
|
||||||
|
"""완전 삭제(hard). 라인의 box_rule_id 는 ON DELETE 미설정이므로
|
||||||
|
참조 중이면 FK 위반 가능 → 참조 라인의 box_rule_id 를 먼저 NULL 처리."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
with conn.transaction():
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE cupang_shipment_lines SET box_rule_id = NULL WHERE box_rule_id = %s",
|
||||||
|
(rule_id,),
|
||||||
|
)
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM cupang_box_rules WHERE id = %s", (rule_id,)
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise KeyError(rule_id)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 제품명 카탈로그 (cupang_products)
|
||||||
|
# itemcode_db 에서 가져와 등록한 제품 목록. 폼의 제품명 드롭다운 소스.
|
||||||
|
# 제품명 선택 → product_code 자동 채움.
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def list_products(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
|
||||||
|
where = "" if include_inactive else "WHERE active = TRUE"
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM cupang_products {where} "
|
||||||
|
"ORDER BY product_code ASC"
|
||||||
|
).fetchall()
|
||||||
|
return [self._product_serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def upsert_product(
|
||||||
|
self, *, product_code: str, product_name: str, sort_order: int = 0
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
code = (product_code or "").strip()
|
||||||
|
name = (product_name or "").strip()
|
||||||
|
if not code or not name:
|
||||||
|
raise ValueError("제품코드와 제품명 모두 필요합니다.")
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cupang_products (product_code, product_name, sort_order)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (product_code) DO UPDATE
|
||||||
|
SET product_name = EXCLUDED.product_name,
|
||||||
|
sort_order = EXCLUDED.sort_order,
|
||||||
|
active = TRUE
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(code, name, sort_order),
|
||||||
|
).fetchone()
|
||||||
|
return self._product_serialize(row)
|
||||||
|
|
||||||
|
def set_product_active(self, *, product_id: int, active: bool) -> None:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE cupang_products SET active = %s WHERE id = %s",
|
||||||
|
(bool(active), product_id),
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise KeyError(product_id)
|
||||||
|
|
||||||
|
def delete_product(self, *, product_id: int) -> None:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM cupang_products WHERE id = %s", (product_id,)
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise KeyError(product_id)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 출고 묶음 (cupang_shipments + cupang_shipment_lines)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def list_shipments(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
year: int | None = None,
|
||||||
|
month: int | None = None,
|
||||||
|
date_from: str | None = None,
|
||||||
|
date_to: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""헤더 목록(라인 제외). 달력/리스트 표시에 사용.
|
||||||
|
|
||||||
|
year+month 가 주어지면 작성일/출고일/센터입고일 중 하나라도 해당 월에
|
||||||
|
걸치는 묶음을 모두 포함한다(달력 표시용).
|
||||||
|
"""
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if year and month:
|
||||||
|
clauses.append(
|
||||||
|
"(date_trunc('month', document_date) = make_date(%s, %s, 1)"
|
||||||
|
" OR date_trunc('month', ship_date) = make_date(%s, %s, 1)"
|
||||||
|
" OR date_trunc('month', center_arrival_date) = make_date(%s, %s, 1))"
|
||||||
|
)
|
||||||
|
params.extend([year, month, year, month, year, month])
|
||||||
|
if date_from:
|
||||||
|
clauses.append("ship_date >= %s")
|
||||||
|
params.append(date_from)
|
||||||
|
if date_to:
|
||||||
|
clauses.append("ship_date <= %s")
|
||||||
|
params.append(date_to)
|
||||||
|
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM cupang_shipments {where} "
|
||||||
|
"ORDER BY ship_date ASC, id ASC",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
return [self._shipment_serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def get_shipment(self, *, shipment_id: int, with_lines: bool = True) -> dict[str, Any] | None:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM cupang_shipments WHERE id = %s", (shipment_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
head = self._shipment_serialize(row)
|
||||||
|
if with_lines:
|
||||||
|
line_rows = conn.execute(
|
||||||
|
"SELECT * FROM cupang_shipment_lines WHERE shipment_id = %s "
|
||||||
|
"ORDER BY line_no ASC",
|
||||||
|
(shipment_id,),
|
||||||
|
).fetchall()
|
||||||
|
head["lines"] = [self._line_serialize(r) for r in line_rows]
|
||||||
|
return head
|
||||||
|
|
||||||
|
def create_shipment(
|
||||||
|
self, *, created_by: str, header: dict[str, Any], lines: list[dict[str, Any]]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
h = self._normalize_header(header)
|
||||||
|
norm_lines = self._normalize_lines(lines)
|
||||||
|
if not norm_lines:
|
||||||
|
raise ValueError("품목 라인이 최소 1개 필요합니다.")
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
with conn.transaction():
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cupang_shipments
|
||||||
|
(created_by, document_date, ship_date,
|
||||||
|
center_arrival_date, center_id, center_name_snapshot,
|
||||||
|
ship_method, outbound_summary, worker, status, memo)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
created_by.lower().strip(),
|
||||||
|
h["document_date"],
|
||||||
|
h["ship_date"],
|
||||||
|
h["center_arrival_date"],
|
||||||
|
h["center_id"],
|
||||||
|
h["center_name_snapshot"],
|
||||||
|
h["ship_method"],
|
||||||
|
h["outbound_summary"],
|
||||||
|
h["worker"],
|
||||||
|
h["status"],
|
||||||
|
h["memo"],
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
shipment_id = row["id"]
|
||||||
|
self._insert_lines(conn, shipment_id, norm_lines)
|
||||||
|
return self.get_shipment(shipment_id=shipment_id)
|
||||||
|
|
||||||
|
def update_shipment(
|
||||||
|
self, *, shipment_id: int, header: dict[str, Any], lines: list[dict[str, Any]]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
h = self._normalize_header(header)
|
||||||
|
norm_lines = self._normalize_lines(lines)
|
||||||
|
if not norm_lines:
|
||||||
|
raise ValueError("품목 라인이 최소 1개 필요합니다.")
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
with conn.transaction():
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE cupang_shipments
|
||||||
|
SET document_date = %s, ship_date = %s,
|
||||||
|
center_arrival_date = %s, center_id = %s,
|
||||||
|
center_name_snapshot = %s, ship_method = %s,
|
||||||
|
outbound_summary = %s, worker = %s, memo = %s
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
h["document_date"],
|
||||||
|
h["ship_date"],
|
||||||
|
h["center_arrival_date"],
|
||||||
|
h["center_id"],
|
||||||
|
h["center_name_snapshot"],
|
||||||
|
h["ship_method"],
|
||||||
|
h["outbound_summary"],
|
||||||
|
h["worker"],
|
||||||
|
h["memo"],
|
||||||
|
shipment_id,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(shipment_id)
|
||||||
|
# 라인 전체 교체 (CASCADE 아님 — 명시적 삭제 후 재삽입)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM cupang_shipment_lines WHERE shipment_id = %s",
|
||||||
|
(shipment_id,),
|
||||||
|
)
|
||||||
|
self._insert_lines(conn, shipment_id, norm_lines)
|
||||||
|
return self.get_shipment(shipment_id=shipment_id)
|
||||||
|
|
||||||
|
def set_status(self, *, shipment_id: int, status: str) -> dict[str, Any]:
|
||||||
|
if status not in STATUSES:
|
||||||
|
raise ValueError(f"허용되지 않는 상태: {status}")
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"UPDATE cupang_shipments SET status = %s WHERE id = %s RETURNING *",
|
||||||
|
(status, shipment_id),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(shipment_id)
|
||||||
|
return self._shipment_serialize(row)
|
||||||
|
|
||||||
|
def soft_delete(self, *, shipment_id: int) -> dict[str, Any]:
|
||||||
|
"""운영 안전을 위한 기본 삭제 — status='취소'."""
|
||||||
|
return self.set_status(shipment_id=shipment_id, status="취소")
|
||||||
|
|
||||||
|
def hard_delete(self, *, shipment_id: int) -> None:
|
||||||
|
"""완전 삭제(라인은 ON DELETE CASCADE). 취소 처리로 충분하므로 기본 미사용."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM cupang_shipments WHERE id = %s", (shipment_id,)
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise KeyError(shipment_id)
|
||||||
|
|
||||||
|
def _insert_lines(self, conn: Any, shipment_id: int, lines: list[dict[str, Any]]) -> None:
|
||||||
|
for idx, ln in enumerate(lines, start=1):
|
||||||
|
calc = compute_boxes(ln["quantity"], ln.get("units_per_box"))
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cupang_shipment_lines
|
||||||
|
(shipment_id, line_no, product_code, product_name_snapshot,
|
||||||
|
quantity, box_rule_id, units_per_box, calculated_boxes,
|
||||||
|
remainder_units, manual_box_text, memo)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
shipment_id,
|
||||||
|
idx,
|
||||||
|
ln["product_code"],
|
||||||
|
ln["product_name_snapshot"],
|
||||||
|
ln["quantity"],
|
||||||
|
ln.get("box_rule_id"),
|
||||||
|
calc["units_per_box"],
|
||||||
|
calc["required_boxes"],
|
||||||
|
calc["remainder_units"],
|
||||||
|
ln.get("manual_box_text", ""),
|
||||||
|
ln.get("memo", ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 달력 집계
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def calendar_counts(self, *, year: int, month: int) -> dict[str, dict[str, int]]:
|
||||||
|
"""해당 월의 날짜별 작성/출고/입고 건수.
|
||||||
|
|
||||||
|
반환: { "YYYY-MM-DD": {"document": n, "ship": n, "arrival": n} }
|
||||||
|
취소 상태는 제외한다.
|
||||||
|
"""
|
||||||
|
first = (year, month)
|
||||||
|
out: dict[str, dict[str, int]] = {}
|
||||||
|
|
||||||
|
def _accumulate(rows: list[dict[str, Any]], key: str) -> None:
|
||||||
|
for r in rows:
|
||||||
|
d = r["d"]
|
||||||
|
ds = d.isoformat() if isinstance(d, date) else str(d)
|
||||||
|
out.setdefault(ds, {"document": 0, "ship": 0, "arrival": 0})
|
||||||
|
out[ds][key] = int(r["c"])
|
||||||
|
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
doc = conn.execute(
|
||||||
|
"SELECT document_date AS d, COUNT(*) AS c FROM cupang_shipments "
|
||||||
|
"WHERE status <> '취소' AND date_trunc('month', document_date) = make_date(%s,%s,1) "
|
||||||
|
"GROUP BY 1",
|
||||||
|
first,
|
||||||
|
).fetchall()
|
||||||
|
ship = conn.execute(
|
||||||
|
"SELECT ship_date AS d, COUNT(*) AS c FROM cupang_shipments "
|
||||||
|
"WHERE status <> '취소' AND date_trunc('month', ship_date) = make_date(%s,%s,1) "
|
||||||
|
"GROUP BY 1",
|
||||||
|
first,
|
||||||
|
).fetchall()
|
||||||
|
arr = conn.execute(
|
||||||
|
"SELECT center_arrival_date AS d, COUNT(*) AS c FROM cupang_shipments "
|
||||||
|
"WHERE status <> '취소' AND date_trunc('month', center_arrival_date) = make_date(%s,%s,1) "
|
||||||
|
"GROUP BY 1",
|
||||||
|
first,
|
||||||
|
).fetchall()
|
||||||
|
_accumulate(doc, "document")
|
||||||
|
_accumulate(ship, "ship")
|
||||||
|
_accumulate(arr, "arrival")
|
||||||
|
return out
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 정규화 / 직렬화 helpers
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_header(header: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
def _d(key: str) -> str:
|
||||||
|
v = str(header.get(key) or "").strip()
|
||||||
|
return v
|
||||||
|
|
||||||
|
document_date = _d("document_date")
|
||||||
|
ship_date = _d("ship_date")
|
||||||
|
center_arrival_date = _d("center_arrival_date")
|
||||||
|
if not document_date or not ship_date or not center_arrival_date:
|
||||||
|
raise ValueError("작성일/출고일/센터입고일은 필수입니다.")
|
||||||
|
|
||||||
|
status = str(header.get("status") or "작성중").strip()
|
||||||
|
if status not in STATUSES:
|
||||||
|
status = "작성중"
|
||||||
|
|
||||||
|
center_id_raw = header.get("center_id")
|
||||||
|
try:
|
||||||
|
center_id = int(center_id_raw) if center_id_raw not in (None, "", "0") else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
center_id = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"document_date": document_date,
|
||||||
|
"ship_date": ship_date,
|
||||||
|
"center_arrival_date": center_arrival_date,
|
||||||
|
"center_id": center_id,
|
||||||
|
"center_name_snapshot": str(header.get("center_name_snapshot") or "").strip(),
|
||||||
|
"ship_method": str(header.get("ship_method") or "택배").strip() or "택배",
|
||||||
|
"outbound_summary": str(header.get("outbound_summary") or "").strip(),
|
||||||
|
"worker": str(header.get("worker") or "").strip(),
|
||||||
|
"status": status,
|
||||||
|
"memo": str(header.get("memo") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_lines(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for raw in lines or []:
|
||||||
|
code = str(raw.get("product_code") or "").strip()
|
||||||
|
name = str(raw.get("product_name_snapshot") or raw.get("product_name") or "").strip()
|
||||||
|
try:
|
||||||
|
qty = int(raw.get("quantity") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
qty = 0
|
||||||
|
if not code or qty <= 0:
|
||||||
|
continue # 빈 라인 스킵
|
||||||
|
upb_raw = raw.get("units_per_box")
|
||||||
|
try:
|
||||||
|
upb = int(upb_raw) if upb_raw not in (None, "", "0") else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
upb = None
|
||||||
|
rule_id_raw = raw.get("box_rule_id")
|
||||||
|
try:
|
||||||
|
rule_id = int(rule_id_raw) if rule_id_raw not in (None, "") else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
rule_id = None
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"product_code": code,
|
||||||
|
"product_name_snapshot": name or code,
|
||||||
|
"quantity": qty,
|
||||||
|
"units_per_box": upb,
|
||||||
|
"box_rule_id": rule_id,
|
||||||
|
"manual_box_text": str(raw.get("manual_box_text") or "").strip(),
|
||||||
|
"memo": str(raw.get("memo") or "").strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _center_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
out = dict(row)
|
||||||
|
out["id"] = int(out["id"])
|
||||||
|
out["active"] = bool(out.get("active", True))
|
||||||
|
out["sort_order"] = int(out.get("sort_order", 0))
|
||||||
|
for k in ("created_at", "updated_at"):
|
||||||
|
v = out.get(k)
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rule_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
out = dict(row)
|
||||||
|
out["id"] = int(out["id"])
|
||||||
|
out["units_per_box"] = int(out.get("units_per_box", 0))
|
||||||
|
out["active"] = bool(out.get("active", True))
|
||||||
|
for k in ("created_at", "updated_at"):
|
||||||
|
v = out.get(k)
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _product_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
out = dict(row)
|
||||||
|
out["id"] = int(out["id"])
|
||||||
|
out["active"] = bool(out.get("active", True))
|
||||||
|
out["sort_order"] = int(out.get("sort_order", 0))
|
||||||
|
for k in ("created_at", "updated_at"):
|
||||||
|
v = out.get(k)
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _shipment_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
out = dict(row)
|
||||||
|
out["id"] = int(out["id"])
|
||||||
|
out["center_id"] = int(out["center_id"]) if out.get("center_id") is not None else None
|
||||||
|
for k in ("document_date", "ship_date", "center_arrival_date"):
|
||||||
|
v = out.get(k)
|
||||||
|
if isinstance(v, date):
|
||||||
|
out[k] = v.isoformat()
|
||||||
|
for k in ("created_at", "updated_at"):
|
||||||
|
v = out.get(k)
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _line_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
out = dict(row)
|
||||||
|
out["id"] = int(out["id"])
|
||||||
|
out["line_no"] = int(out.get("line_no", 0))
|
||||||
|
out["quantity"] = int(out.get("quantity", 0))
|
||||||
|
for k in ("box_rule_id", "units_per_box", "calculated_boxes", "remainder_units"):
|
||||||
|
v = out.get(k)
|
||||||
|
out[k] = int(v) if v is not None else None
|
||||||
|
for k in ("created_at", "updated_at"):
|
||||||
|
v = out.get(k)
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||||
|
return out
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""대한민국 공휴일 판정 (달력 색상용).
|
||||||
|
|
||||||
|
- 고정 양력 공휴일은 매년 동일 → 연도 무관 판정.
|
||||||
|
- 음력 공휴일(설날/부처님오신날/추석)과 대체공휴일은 매년 달라짐 →
|
||||||
|
연도별 dict(`_LUNAR_AND_SUBSTITUTE`)에 명시. 새 연도는 KASI 발표값을 추가한다.
|
||||||
|
|
||||||
|
미수록 연도는 고정 양력 공휴일만 빨강 처리된다(음력/대체는 누락).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
# 매년 동일한 양력 공휴일 (month, day)
|
||||||
|
_FIXED_SOLAR: set[tuple[int, int]] = {
|
||||||
|
(1, 1), # 신정
|
||||||
|
(3, 1), # 삼일절
|
||||||
|
(5, 5), # 어린이날
|
||||||
|
(6, 6), # 현충일
|
||||||
|
(8, 15), # 광복절
|
||||||
|
(10, 3), # 개천절
|
||||||
|
(10, 9), # 한글날
|
||||||
|
(12, 25), # 성탄절
|
||||||
|
}
|
||||||
|
|
||||||
|
# 연도별 음력 공휴일 + 대체공휴일 (ISO 날짜 문자열). KASI 발표 기준.
|
||||||
|
_LUNAR_AND_SUBSTITUTE: dict[int, set[str]] = {
|
||||||
|
2025: {
|
||||||
|
"2025-01-28", "2025-01-29", "2025-01-30", # 설날 연휴
|
||||||
|
"2025-03-03", # 삼일절 대체(3/1 토)
|
||||||
|
"2025-05-06", # 부처님오신날 대체(5/5 겹침)
|
||||||
|
"2025-05-05", # 부처님오신날(어린이날과 동일일)
|
||||||
|
"2025-10-06", "2025-10-07", "2025-10-08", # 추석 연휴
|
||||||
|
"2025-10-08", # 추석 대체 가능
|
||||||
|
},
|
||||||
|
2026: {
|
||||||
|
"2026-02-16", "2026-02-17", "2026-02-18", # 설날 연휴 (설날 2/17)
|
||||||
|
"2026-03-02", # 삼일절 대체 (3/1 일)
|
||||||
|
"2026-05-24", # 부처님오신날 (일)
|
||||||
|
"2026-05-25", # 부처님오신날 대체
|
||||||
|
"2026-08-17", # 광복절 대체 (8/15 토)
|
||||||
|
"2026-09-24", "2026-09-25", "2026-09-26", # 추석 연휴 (추석 9/25)
|
||||||
|
"2026-09-28", # 추석 대체 (9/26 토)
|
||||||
|
"2026-10-05", # 개천절 대체 (10/3 토)
|
||||||
|
},
|
||||||
|
2027: {
|
||||||
|
"2027-02-06", "2027-02-07", "2027-02-08", # 설날 연휴 (설날 2/7)
|
||||||
|
"2027-02-09", # 설날 대체 (2/7 일)
|
||||||
|
"2027-05-13", # 부처님오신날 (목)
|
||||||
|
"2027-08-16", # 광복절 대체 (8/15 일)
|
||||||
|
"2027-09-14", "2027-09-15", "2027-09-16", # 추석 연휴 (추석 9/15)
|
||||||
|
"2027-10-04", # 개천절 대체 (10/3 일)
|
||||||
|
"2027-10-11", # 한글날 대체 (10/9 토)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_holiday(d: date) -> bool:
|
||||||
|
"""공휴일(일요일 제외)이면 True. 일/토 색상은 요일로 따로 판정한다."""
|
||||||
|
if (d.month, d.day) in _FIXED_SOLAR:
|
||||||
|
return True
|
||||||
|
return d.isoformat() in _LUNAR_AND_SUBSTITUTE.get(d.year, set())
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
r"""itemcode_db 읽기 전용 상품 검색.
|
||||||
|
|
||||||
|
cupang 모듈은 itemcode_db 의 상품(낱개코드/세트코드)을 **읽기만** 한다.
|
||||||
|
cupang_db 에 상품을 복제 저장하지 않는다. 출고 라인에는 product_code 와
|
||||||
|
product_name_snapshot 만 보존한다.
|
||||||
|
|
||||||
|
⚠️ itemcode_db 의 실제 테이블/컬럼명은 이 저장소(main-app)에 정의되어 있지 않다.
|
||||||
|
운영 서버에서 다음으로 먼저 구조를 확인한 뒤 환경변수를 설정해야 한다:
|
||||||
|
|
||||||
|
docker exec -it postgres-db psql -U postgres -d itemcode_db -c "\dt"
|
||||||
|
docker exec -it postgres-db psql -U postgres -d itemcode_db -c "\d <테이블명>"
|
||||||
|
|
||||||
|
환경변수 (모두 미설정 시 검색 비활성 → 폼에서 수동 입력으로 폴백):
|
||||||
|
|
||||||
|
ITEMCODE_DB_URL 읽기 전용 DSN. 예: postgresql://itemcode_ro:<pwd>@postgres-db:5432/itemcode_db
|
||||||
|
ITEMCODE_SEARCH_SQL (선택) 검색 SQL 직접 지정. 아래 자동 생성 대신 사용.
|
||||||
|
반드시 code, name, type 컬럼을 별칭으로 반환하고,
|
||||||
|
%(q)s 파라미터를 LIKE 패턴으로 받는다.
|
||||||
|
|
||||||
|
자동 생성용 (ITEMCODE_SEARCH_SQL 미설정 시):
|
||||||
|
ITEMCODE_TABLE 검색 대상 테이블/뷰 (예: products 또는 item_master)
|
||||||
|
ITEMCODE_CODE_COL 코드 컬럼명 (기본: product_code)
|
||||||
|
ITEMCODE_NAME_COL 상품명 컬럼명 (기본: product_name)
|
||||||
|
ITEMCODE_TYPE_COL (선택) 단품/세트 구분 컬럼명. 없으면 type 은 빈 문자열.
|
||||||
|
|
||||||
|
낱개코드와 세트코드가 별도 테이블이면 ITEMCODE_SEARCH_SQL 에 UNION 으로 직접 작성한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger("cupang.itemcode")
|
||||||
|
|
||||||
|
# 안전한 SQL 식별자(테이블/컬럼)만 허용 — 인젝션 방지.
|
||||||
|
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _ident(value: str, *, what: str) -> str:
|
||||||
|
v = (value or "").strip()
|
||||||
|
if not _IDENT_RE.match(v):
|
||||||
|
raise ValueError(f"안전하지 않은 {what} 식별자: {value!r}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ItemcodeReader:
|
||||||
|
"""itemcode_db 읽기 전용 커넥션 풀 + 상품 검색.
|
||||||
|
|
||||||
|
설정이 없거나 불완전하면 `enabled=False` 로 두고, search()는 빈 리스트를 반환한다.
|
||||||
|
앱 부팅이나 cupang 모듈 진입을 막지 않는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._pool: Any = None
|
||||||
|
self._sql: str | None = None
|
||||||
|
self.enabled = False
|
||||||
|
self.reason = ""
|
||||||
|
self.last_error = "" # 마지막 조회 오류(진단용, 비밀값 없음)
|
||||||
|
self._configure()
|
||||||
|
|
||||||
|
def _configure(self) -> None:
|
||||||
|
dsn = os.getenv("ITEMCODE_DB_URL", "").strip()
|
||||||
|
if not dsn:
|
||||||
|
self.reason = "ITEMCODE_DB_URL 미설정 — 상품 검색 비활성(수동 입력 사용)."
|
||||||
|
return
|
||||||
|
|
||||||
|
custom_sql = os.getenv("ITEMCODE_SEARCH_SQL", "").strip()
|
||||||
|
if custom_sql:
|
||||||
|
self._sql = custom_sql
|
||||||
|
else:
|
||||||
|
table = os.getenv("ITEMCODE_TABLE", "").strip()
|
||||||
|
if not table:
|
||||||
|
self.reason = (
|
||||||
|
"ITEMCODE_TABLE(또는 ITEMCODE_SEARCH_SQL) 미설정 — "
|
||||||
|
"상품 검색 비활성(수동 입력 사용)."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
table_id = _ident(table, what="테이블")
|
||||||
|
code_col = _ident(os.getenv("ITEMCODE_CODE_COL", "product_code"), what="코드 컬럼")
|
||||||
|
name_col = _ident(os.getenv("ITEMCODE_NAME_COL", "product_name"), what="상품명 컬럼")
|
||||||
|
type_col_raw = os.getenv("ITEMCODE_TYPE_COL", "").strip()
|
||||||
|
type_expr = _ident(type_col_raw, what="구분 컬럼") if type_col_raw else "''"
|
||||||
|
except ValueError as exc:
|
||||||
|
self.reason = f"itemcode 검색 설정 오류: {exc}"
|
||||||
|
return
|
||||||
|
self._sql = (
|
||||||
|
f"SELECT {code_col} AS code, {name_col} AS name, {type_expr} AS type "
|
||||||
|
f"FROM {table_id} "
|
||||||
|
f"WHERE {code_col} ILIKE %(q)s OR {name_col} ILIKE %(q)s "
|
||||||
|
f"ORDER BY {code_col} ASC LIMIT %(limit)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 풀은 lazy open — 부팅 시 itemcode_db 가 잠시 끊겨도 죽지 않게.
|
||||||
|
try:
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
|
self._pool = ConnectionPool(
|
||||||
|
conninfo=dsn,
|
||||||
|
min_size=1,
|
||||||
|
max_size=3,
|
||||||
|
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||||
|
open=False,
|
||||||
|
)
|
||||||
|
self._pool.open(wait=False)
|
||||||
|
self.enabled = True
|
||||||
|
self.reason = ""
|
||||||
|
except Exception as exc: # noqa: BLE001 — 설정/드라이버 문제로 모듈을 죽이지 않음
|
||||||
|
self.reason = f"itemcode_db 연결 풀 생성 실패: {type(exc).__name__}"
|
||||||
|
|
||||||
|
def search(self, query: str, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
"""code/name 부분 일치 검색. 반환: [{"code","name","type"}].
|
||||||
|
|
||||||
|
비활성 상태이거나 조회 실패 시 빈 리스트(예외 비전파 — UI 는 수동 입력 폴백).
|
||||||
|
"""
|
||||||
|
q = (query or "").strip()
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
return self._run(f"%{q}%", limit)
|
||||||
|
|
||||||
|
def list_all(self, *, limit: int = 2000) -> list[dict[str, Any]]:
|
||||||
|
"""전체 상품 목록(낱개+세트). 설정 화면 왼쪽 리스트용."""
|
||||||
|
return self._run("%", limit)
|
||||||
|
|
||||||
|
def _run(self, like: str, limit: int) -> list[dict[str, Any]]:
|
||||||
|
if not self.enabled or not self._pool or not self._sql:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
self._sql, {"q": like, "limit": int(limit)}
|
||||||
|
).fetchall()
|
||||||
|
self.last_error = ""
|
||||||
|
except Exception as exc: # noqa: BLE001 — 모듈을 죽이지 않음. 원인은 로그 + last_error.
|
||||||
|
self.last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
logger.exception("itemcode 조회 실패 (SQL/스키마 확인 필요)")
|
||||||
|
return []
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for r in rows:
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"code": str(r.get("code") or "").strip(),
|
||||||
|
"name": str(r.get("name") or "").strip(),
|
||||||
|
"type": str(r.get("type") or "").strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._pool is not None:
|
||||||
|
self._pool.close()
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
"""쿠팡 밀크런 모듈 라우터.
|
||||||
|
|
||||||
|
- 경로: /cupang
|
||||||
|
- 권한: 로그인 + `cupang` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
|
||||||
|
- 데이터: CupangDBStore (cupang_db / PostgreSQL) 전용.
|
||||||
|
CUPANG_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내를 보여준다.
|
||||||
|
- 상품 검색: itemcode_db 읽기 전용(ItemcodeReader). 미설정 시 수동 입력 폴백.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar as _calendar
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.timezone import today_kst
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
from .holidays import is_holiday
|
||||||
|
from .store import SHIP_METHODS
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/cupang", tags=["cupang"])
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 공용 헬퍼
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
def _store(request: Request) -> Any:
|
||||||
|
"""CupangDBStore 또는 None(CUPANG_DB_URL 미설정)."""
|
||||||
|
return getattr(request.app.state, "cupang_store", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _itemcode(request: Request) -> Any:
|
||||||
|
return getattr(request.app.state, "itemcode_reader", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_user(request: Request) -> dict[str, Any]:
|
||||||
|
from app.main import get_current_user_record # noqa: WPS433
|
||||||
|
from app.store import has_module # noqa: WPS433
|
||||||
|
|
||||||
|
user = get_current_user_record(request)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||||
|
if not has_module(user, "cupang"):
|
||||||
|
raise HTTPException(status_code=403, detail="쿠팡 밀크런 모듈 권한이 없습니다.")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
|
||||||
|
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"denied.html",
|
||||||
|
{
|
||||||
|
"reason": "쿠팡 밀크런 모듈이 아직 설정되지 않았습니다. "
|
||||||
|
"CUPANG_DB_URL 환경변수를 설정하고 scripts/sql/cupang_db_init.sql 로 "
|
||||||
|
"cupang_db 를 초기화한 뒤 컨테이너를 재기동하세요.",
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"nav_items": build_erp_nav(user, active="cupang"),
|
||||||
|
},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _guard(request: Request) -> tuple[Any, dict[str, Any]] | HTMLResponse | RedirectResponse:
|
||||||
|
"""로그인+권한+store 점검을 한 번에. 페이지 핸들러 진입부에서 사용."""
|
||||||
|
from app.main import get_current_user_record, render_template # noqa: WPS433
|
||||||
|
from app.store import has_module, is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
user = get_current_user_record(request)
|
||||||
|
if user is None:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
if not has_module(user, "cupang"):
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"denied.html",
|
||||||
|
{"reason": "쿠팡 밀크런 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
return _render_config_needed(request, user)
|
||||||
|
return store, user
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_lines(lines_json: str) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
data = json.loads(lines_json or "[]")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="라인 데이터 형식 오류")
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise HTTPException(status_code=400, detail="라인 데이터는 배열이어야 합니다.")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _ym(request: Request) -> tuple[int, int]:
|
||||||
|
today = today_kst()
|
||||||
|
try:
|
||||||
|
year = int(request.query_params.get("year") or today.year)
|
||||||
|
month = int(request.query_params.get("month") or today.month)
|
||||||
|
except ValueError:
|
||||||
|
year, month = today.year, today.month
|
||||||
|
if not (1 <= month <= 12):
|
||||||
|
year, month = today.year, today.month
|
||||||
|
return year, month
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 메인 — 월간 달력 + 선택일 출고 리스트
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request) -> HTMLResponse:
|
||||||
|
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
guard = _guard(request)
|
||||||
|
if not isinstance(guard, tuple):
|
||||||
|
return guard
|
||||||
|
store, user = guard
|
||||||
|
|
||||||
|
year, month = _ym(request)
|
||||||
|
counts = store.calendar_counts(year=year, month=month)
|
||||||
|
shipments = store.list_shipments(year=year, month=month)
|
||||||
|
|
||||||
|
# 선택 날짜 (기본: 오늘이 이번 달이면 오늘, 아니면 1일)
|
||||||
|
sel = request.query_params.get("date") or ""
|
||||||
|
today = today_kst()
|
||||||
|
if not sel:
|
||||||
|
sel = today.isoformat() if (today.year == year and today.month == month) else f"{year:04d}-{month:02d}-01"
|
||||||
|
|
||||||
|
# 선택일에 걸친 묶음(출고일 기준 우선, 작성/입고 포함)
|
||||||
|
sel_shipments = [
|
||||||
|
s for s in shipments
|
||||||
|
if sel in (s.get("ship_date"), s.get("document_date"), s.get("center_arrival_date"))
|
||||||
|
]
|
||||||
|
# 각 묶음에 품목 요약(제품명/수량) 첨부 — hover 툴팁용
|
||||||
|
for s in sel_shipments:
|
||||||
|
full = store.get_shipment(shipment_id=s["id"])
|
||||||
|
s["tip_items"] = [
|
||||||
|
{"name": ln.get("product_name_snapshot") or ln.get("product_code"),
|
||||||
|
"qty": ln.get("quantity", 0)}
|
||||||
|
for ln in (full.get("lines") if full else [])
|
||||||
|
]
|
||||||
|
|
||||||
|
cal = _calendar.Calendar(firstweekday=6) # 일요일 시작
|
||||||
|
weeks = cal.monthdatescalendar(year, month)
|
||||||
|
cal_weeks = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"date": d.isoformat(),
|
||||||
|
"day": d.day,
|
||||||
|
"in_month": d.month == month,
|
||||||
|
"is_today": d == today,
|
||||||
|
"is_selected": d.isoformat() == sel,
|
||||||
|
"is_sunday": d.weekday() == 6,
|
||||||
|
"is_saturday": d.weekday() == 5,
|
||||||
|
"is_holiday": is_holiday(d),
|
||||||
|
"counts": counts.get(d.isoformat(), {}),
|
||||||
|
}
|
||||||
|
for d in week
|
||||||
|
]
|
||||||
|
for week in weeks
|
||||||
|
]
|
||||||
|
|
||||||
|
prev_y, prev_m = (year - 1, 12) if month == 1 else (year, month - 1)
|
||||||
|
next_y, next_m = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"cupang/index.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"nav_items": build_erp_nav(user, active="cupang"),
|
||||||
|
"page_title": "쿠팡 밀크런",
|
||||||
|
"page_subtitle": f"{year}년 {month}월 출고 일정",
|
||||||
|
"year": year,
|
||||||
|
"month": month,
|
||||||
|
"prev_y": prev_y, "prev_m": prev_m,
|
||||||
|
"next_y": next_y, "next_m": next_m,
|
||||||
|
"weekdays": ["일", "월", "화", "수", "목", "금", "토"],
|
||||||
|
"cal_weeks": cal_weeks,
|
||||||
|
"selected_date": sel,
|
||||||
|
"sel_shipments": sel_shipments,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 출고 묶음 — 등록 / 수정 / 상세
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def _form_context(request: Request, store: Any, user: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
from app.main import build_erp_nav # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
reader = _itemcode(request)
|
||||||
|
return {
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"nav_items": build_erp_nav(user, active="cupang"),
|
||||||
|
"centers": sorted(store.list_centers(), key=lambda c: c["name"]),
|
||||||
|
"box_rules": store.list_box_rules(),
|
||||||
|
"products": store.list_products(),
|
||||||
|
"ship_methods": list(SHIP_METHODS),
|
||||||
|
"search_enabled": bool(reader and reader.enabled),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/new", response_class=HTMLResponse)
|
||||||
|
async def new_form(request: Request) -> HTMLResponse:
|
||||||
|
from app.main import render_template # noqa: WPS433
|
||||||
|
|
||||||
|
guard = _guard(request)
|
||||||
|
if not isinstance(guard, tuple):
|
||||||
|
return guard
|
||||||
|
store, user = guard
|
||||||
|
ctx = _form_context(request, store, user)
|
||||||
|
ctx.update(
|
||||||
|
{
|
||||||
|
"page_title": "쿠팡 밀크런 — 신규 등록",
|
||||||
|
"page_subtitle": "공통 헤더 1개 + 품목 라인",
|
||||||
|
"mode": "new",
|
||||||
|
"shipment": None,
|
||||||
|
"default_date": today_kst().isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return render_template(request, "cupang/form.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/new")
|
||||||
|
async def create(
|
||||||
|
request: Request,
|
||||||
|
lines_json: str = Form("[]"),
|
||||||
|
document_date: str = Form(...),
|
||||||
|
ship_date: str = Form(...),
|
||||||
|
center_arrival_date: str = Form(...),
|
||||||
|
center_id: str = Form(""),
|
||||||
|
center_name_snapshot: str = Form(""),
|
||||||
|
ship_method: str = Form("택배"),
|
||||||
|
outbound_summary: str = Form(""),
|
||||||
|
worker: str = Form(""),
|
||||||
|
memo: str = Form(""),
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
header = {
|
||||||
|
"document_date": document_date,
|
||||||
|
"ship_date": ship_date,
|
||||||
|
"center_arrival_date": center_arrival_date,
|
||||||
|
"center_id": center_id,
|
||||||
|
"center_name_snapshot": center_name_snapshot,
|
||||||
|
"ship_method": ship_method,
|
||||||
|
"outbound_summary": outbound_summary,
|
||||||
|
"worker": worker,
|
||||||
|
"memo": memo,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
ship = store.create_shipment(
|
||||||
|
created_by=user["email"], header=header, lines=_parse_lines(lines_json)
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return RedirectResponse(url=f"/cupang/{ship['id']}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{shipment_id:int}", response_class=HTMLResponse)
|
||||||
|
async def detail(request: Request, shipment_id: int) -> HTMLResponse:
|
||||||
|
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
guard = _guard(request)
|
||||||
|
if not isinstance(guard, tuple):
|
||||||
|
return guard
|
||||||
|
store, user = guard
|
||||||
|
ship = store.get_shipment(shipment_id=shipment_id)
|
||||||
|
if not ship:
|
||||||
|
return render_template(
|
||||||
|
request, "denied.html",
|
||||||
|
{"reason": "출고 묶음을 찾을 수 없습니다.", "is_admin": is_admin(user)},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"cupang/detail.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"nav_items": build_erp_nav(user, active="cupang"),
|
||||||
|
"page_title": f"출고 #{ship['id']}",
|
||||||
|
"page_subtitle": f"{ship['ship_date']} · {ship['center_name_snapshot']}",
|
||||||
|
"shipment": ship,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{shipment_id:int}/edit", response_class=HTMLResponse)
|
||||||
|
async def edit_form(request: Request, shipment_id: int) -> HTMLResponse:
|
||||||
|
from app.main import render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
guard = _guard(request)
|
||||||
|
if not isinstance(guard, tuple):
|
||||||
|
return guard
|
||||||
|
store, user = guard
|
||||||
|
ship = store.get_shipment(shipment_id=shipment_id)
|
||||||
|
if not ship:
|
||||||
|
return render_template(
|
||||||
|
request, "denied.html",
|
||||||
|
{"reason": "출고 묶음을 찾을 수 없습니다.", "is_admin": is_admin(user)},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
ctx = _form_context(request, store, user)
|
||||||
|
ctx.update(
|
||||||
|
{
|
||||||
|
"page_title": f"출고 #{ship['id']} 수정",
|
||||||
|
"page_subtitle": "헤더/라인 수정 후 저장",
|
||||||
|
"mode": "edit",
|
||||||
|
"shipment": ship,
|
||||||
|
"default_date": ship["document_date"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return render_template(request, "cupang/form.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{shipment_id:int}/edit")
|
||||||
|
async def update(
|
||||||
|
request: Request,
|
||||||
|
shipment_id: int,
|
||||||
|
lines_json: str = Form("[]"),
|
||||||
|
document_date: str = Form(...),
|
||||||
|
ship_date: str = Form(...),
|
||||||
|
center_arrival_date: str = Form(...),
|
||||||
|
center_id: str = Form(""),
|
||||||
|
center_name_snapshot: str = Form(""),
|
||||||
|
ship_method: str = Form("택배"),
|
||||||
|
outbound_summary: str = Form(""),
|
||||||
|
worker: str = Form(""),
|
||||||
|
memo: str = Form(""),
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
header = {
|
||||||
|
"document_date": document_date,
|
||||||
|
"ship_date": ship_date,
|
||||||
|
"center_arrival_date": center_arrival_date,
|
||||||
|
"center_id": center_id,
|
||||||
|
"center_name_snapshot": center_name_snapshot,
|
||||||
|
"ship_method": ship_method,
|
||||||
|
"outbound_summary": outbound_summary,
|
||||||
|
"worker": worker,
|
||||||
|
"memo": memo,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
store.update_shipment(
|
||||||
|
shipment_id=shipment_id, header=header, lines=_parse_lines(lines_json)
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return RedirectResponse(url=f"/cupang/{shipment_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{shipment_id:int}/delete")
|
||||||
|
async def delete(
|
||||||
|
request: Request,
|
||||||
|
shipment_id: int,
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
"""운영 안전: 기본은 status='취소' soft delete."""
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
try:
|
||||||
|
store.soft_delete(shipment_id=shipment_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
|
||||||
|
return RedirectResponse(url=f"/cupang/{shipment_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{shipment_id:int}/hard-delete")
|
||||||
|
async def hard_delete(
|
||||||
|
request: Request,
|
||||||
|
shipment_id: int,
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
"""완전 삭제(헤더+라인 CASCADE). 달력으로 복귀."""
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
try:
|
||||||
|
store.hard_delete(shipment_id=shipment_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
|
||||||
|
return RedirectResponse(url="/cupang/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 입고센터 관리
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
@router.get("/centers", response_class=HTMLResponse)
|
||||||
|
async def centers_page(request: Request) -> HTMLResponse:
|
||||||
|
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
guard = _guard(request)
|
||||||
|
if not isinstance(guard, tuple):
|
||||||
|
return guard
|
||||||
|
store, user = guard
|
||||||
|
centers = sorted(store.list_centers(include_inactive=True), key=lambda c: c["name"])
|
||||||
|
# 사용 중 여부 표시
|
||||||
|
for c in centers:
|
||||||
|
c["in_use"] = store.center_in_use(center_id=c["id"])
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"cupang/centers.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"nav_items": build_erp_nav(user, active="cupang"),
|
||||||
|
"page_title": "쿠팡 밀크런 — 입고센터 관리",
|
||||||
|
"page_subtitle": "추가 · 수정 · 비활성화. 사용 중 센터는 삭제되지 않고 비활성화됩니다.",
|
||||||
|
"centers": centers,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/centers")
|
||||||
|
async def center_create(
|
||||||
|
request: Request,
|
||||||
|
name: str = Form(...),
|
||||||
|
sort_order: int = Form(0),
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
try:
|
||||||
|
store.create_center(name=name, sort_order=sort_order)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return RedirectResponse(url="/cupang/centers", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/centers/{center_id}/edit")
|
||||||
|
async def center_edit(
|
||||||
|
request: Request,
|
||||||
|
center_id: int,
|
||||||
|
name: str = Form(""),
|
||||||
|
active: str = Form(""),
|
||||||
|
sort_order: str = Form(""),
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
kwargs: dict[str, Any] = {"center_id": center_id}
|
||||||
|
if name.strip():
|
||||||
|
kwargs["name"] = name
|
||||||
|
if active != "":
|
||||||
|
kwargs["active"] = active in ("1", "true", "on", "True")
|
||||||
|
if sort_order.strip():
|
||||||
|
try:
|
||||||
|
kwargs["sort_order"] = int(sort_order)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
store.update_center(**kwargs)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="센터를 찾을 수 없습니다.")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return RedirectResponse(url="/cupang/centers", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/centers/{center_id}/delete")
|
||||||
|
async def center_delete(
|
||||||
|
request: Request,
|
||||||
|
center_id: int,
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
try:
|
||||||
|
store.delete_center(center_id=center_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="센터를 찾을 수 없습니다.")
|
||||||
|
return RedirectResponse(url="/cupang/centers", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 박스 입수량 관리
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
@router.get("/box-rules", response_class=HTMLResponse)
|
||||||
|
async def box_rules_page(request: Request) -> HTMLResponse:
|
||||||
|
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
guard = _guard(request)
|
||||||
|
if not isinstance(guard, tuple):
|
||||||
|
return guard
|
||||||
|
store, user = guard
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"cupang/box_rules.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"nav_items": build_erp_nav(user, active="cupang"),
|
||||||
|
"page_title": "쿠팡 밀크런 — 박스 입수량",
|
||||||
|
"page_subtitle": "제품코드별 쿠팡박스 1박스당 입수량 설정",
|
||||||
|
"box_rules": store.list_box_rules(include_inactive=True),
|
||||||
|
"products": store.list_products(),
|
||||||
|
"search_enabled": bool((_itemcode(request)) and _itemcode(request).enabled),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/box-rules")
|
||||||
|
async def box_rule_upsert(
|
||||||
|
request: Request,
|
||||||
|
product_code: str = Form(...),
|
||||||
|
units_per_box: int = Form(...),
|
||||||
|
product_name_snapshot: str = Form(""),
|
||||||
|
box_name: str = Form("쿠팡박스"),
|
||||||
|
memo: str = Form(""),
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
try:
|
||||||
|
store.upsert_box_rule(
|
||||||
|
product_code=product_code,
|
||||||
|
units_per_box=units_per_box,
|
||||||
|
product_name_snapshot=product_name_snapshot,
|
||||||
|
box_name=box_name,
|
||||||
|
memo=memo,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return RedirectResponse(url="/cupang/box-rules", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/box-rules/{rule_id}/delete")
|
||||||
|
async def box_rule_delete(
|
||||||
|
request: Request,
|
||||||
|
rule_id: int,
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
try:
|
||||||
|
store.delete_box_rule(rule_id=rule_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="규칙을 찾을 수 없습니다.")
|
||||||
|
return RedirectResponse(url="/cupang/box-rules", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 설정 — 제품명 카탈로그 관리 (itemcode_db 에서 등록)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
@router.get("/products", response_class=HTMLResponse)
|
||||||
|
async def products_page(request: Request) -> HTMLResponse:
|
||||||
|
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
guard = _guard(request)
|
||||||
|
if not isinstance(guard, tuple):
|
||||||
|
return guard
|
||||||
|
store, user = guard
|
||||||
|
reader = _itemcode(request)
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"cupang/products.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"nav_items": build_erp_nav(user, active="cupang"),
|
||||||
|
"page_title": "쿠팡 밀크런 — 설정 (제품명)",
|
||||||
|
"page_subtitle": "왼쪽 itemcode_db 목록에서 선택해 등록하면 폼 드롭다운에 노출됩니다.",
|
||||||
|
"products": store.list_products(include_inactive=True),
|
||||||
|
"registered_codes": [p["product_code"] for p in store.list_products(include_inactive=True)],
|
||||||
|
"search_enabled": bool(reader and reader.enabled),
|
||||||
|
"search_reason": (reader.reason if reader else ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/products/all")
|
||||||
|
async def product_all(
|
||||||
|
request: Request, _: dict[str, Any] = Depends(_require_user)
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""itemcode_db 전체 상품 목록(낱개+세트). 설정 화면 왼쪽 리스트 소스."""
|
||||||
|
reader = _itemcode(request)
|
||||||
|
results = reader.list_all() if reader else []
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"enabled": bool(reader and reader.enabled),
|
||||||
|
"reason": (reader.reason if reader else "itemcode 리더 미초기화"),
|
||||||
|
"error": (getattr(reader, "last_error", "") if reader else ""),
|
||||||
|
"count": len(results),
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/products/bulk")
|
||||||
|
async def product_bulk(
|
||||||
|
request: Request,
|
||||||
|
items: list[dict[str, Any]] = Body(..., embed=True),
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""선택한 상품들을 일괄 등록(upsert). body: {"items":[{"code","name"}, ...]}."""
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
added = 0
|
||||||
|
for it in items:
|
||||||
|
code = str(it.get("code") or "").strip()
|
||||||
|
name = str(it.get("name") or "").strip()
|
||||||
|
if not code or not name:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
store.upsert_product(product_code=code, product_name=name)
|
||||||
|
added += 1
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return JSONResponse({"ok": True, "added": added})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/products")
|
||||||
|
async def product_upsert(
|
||||||
|
request: Request,
|
||||||
|
product_code: str = Form(...),
|
||||||
|
product_name: str = Form(...),
|
||||||
|
sort_order: int = Form(0),
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
try:
|
||||||
|
store.upsert_product(
|
||||||
|
product_code=product_code, product_name=product_name, sort_order=sort_order
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return RedirectResponse(url="/cupang/products", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/products/{product_id:int}/active")
|
||||||
|
async def product_set_active(
|
||||||
|
request: Request,
|
||||||
|
product_id: int,
|
||||||
|
active: str = Form(...),
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
try:
|
||||||
|
store.set_product_active(
|
||||||
|
product_id=product_id, active=active in ("1", "true", "on", "True")
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.")
|
||||||
|
return RedirectResponse(url="/cupang/products", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/products/{product_id:int}/delete")
|
||||||
|
async def product_delete(
|
||||||
|
request: Request,
|
||||||
|
product_id: int,
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
"""완전 삭제(hard delete)."""
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
try:
|
||||||
|
store.delete_product(product_id=product_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.")
|
||||||
|
return RedirectResponse(url="/cupang/products", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok", "module": "cupang"}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""쿠팡 밀크런 모듈 상수 및 공용 헬퍼.
|
||||||
|
|
||||||
|
- 데이터 저장은 cupang_db(PostgreSQL) 전용이다(`db.py`).
|
||||||
|
운영 데이터가 JSON 과 DB 로 갈라지는 것을 막기 위해 JSON 폴백을 두지 않는다.
|
||||||
|
CUPANG_DB_URL 미설정 시 라우터가 "설정 필요" 안내 페이지를 보여준다.
|
||||||
|
- 이 모듈에는 DB/JSON 양쪽이 공유하는 상수와 순수 계산 헬퍼만 둔다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# 출고 묶음 상태 (expense 의 STATUSES 패턴과 동일하게 한글 라벨 그대로 저장)
|
||||||
|
STATUSES: tuple[str, ...] = (
|
||||||
|
"작성중",
|
||||||
|
"출고준비",
|
||||||
|
"출고완료",
|
||||||
|
"센터입고완료",
|
||||||
|
"취소",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 출고방식 기본 후보 (자유 입력 허용, 아래는 select 기본값)
|
||||||
|
SHIP_METHODS: tuple[str, ...] = ("택배", "직접배송", "화물", "파렛트", "기타")
|
||||||
|
|
||||||
|
# 초기 입고센터 seed — cupang_db_init.sql 에도 동일 목록을 INSERT 한다.
|
||||||
|
# 화면에서 추가/수정/비활성화 가능. 사용 중인 센터는 hard delete 하지 않는다.
|
||||||
|
DEFAULT_CENTERS: tuple[str, ...] = (
|
||||||
|
"대구3",
|
||||||
|
"인천32",
|
||||||
|
"이천1",
|
||||||
|
"인천42",
|
||||||
|
"인천26",
|
||||||
|
"인천16",
|
||||||
|
"인천28",
|
||||||
|
"안성8",
|
||||||
|
"천안8(RC)",
|
||||||
|
"시흥2",
|
||||||
|
"인천36",
|
||||||
|
"MGMH5",
|
||||||
|
"XRC10(RC)",
|
||||||
|
"인천14",
|
||||||
|
"경기광주5",
|
||||||
|
"경기광주3",
|
||||||
|
"XRC06(RC)",
|
||||||
|
"용인1",
|
||||||
|
"인천30",
|
||||||
|
"마장1",
|
||||||
|
"안성4",
|
||||||
|
"대구6",
|
||||||
|
"전라광주2",
|
||||||
|
"창원1",
|
||||||
|
"고양1",
|
||||||
|
"동탄1",
|
||||||
|
"이천4",
|
||||||
|
"XRC09(RC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_boxes(quantity: int, units_per_box: int | None) -> dict[str, Any]:
|
||||||
|
"""수량 + 박스당 입수량으로 필요한 박스 수를 계산한다.
|
||||||
|
|
||||||
|
클라이언트 계산을 신뢰하지 않고 서버에서 이 함수로 재계산한다.
|
||||||
|
|
||||||
|
- units_per_box 가 없거나 0 이하면 "미설정" — 자동 계산하지 않는다.
|
||||||
|
- full_boxes = quantity // units_per_box
|
||||||
|
- remainder_units = quantity % units_per_box
|
||||||
|
- required_boxes = ceil(quantity / units_per_box)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
qty = int(quantity)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
qty = 0
|
||||||
|
|
||||||
|
upb: int | None
|
||||||
|
try:
|
||||||
|
upb = int(units_per_box) if units_per_box is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
upb = None
|
||||||
|
|
||||||
|
if not upb or upb <= 0 or qty <= 0:
|
||||||
|
return {
|
||||||
|
"configured": False,
|
||||||
|
"units_per_box": upb if (upb and upb > 0) else None,
|
||||||
|
"full_boxes": None,
|
||||||
|
"remainder_units": None,
|
||||||
|
"required_boxes": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"configured": True,
|
||||||
|
"units_per_box": upb,
|
||||||
|
"full_boxes": qty // upb,
|
||||||
|
"remainder_units": qty % upb,
|
||||||
|
"required_boxes": math.ceil(qty / upb),
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="cpg">
|
||||||
|
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cpg-brule-layout">
|
||||||
|
|
||||||
|
<!-- 왼쪽: 추가/수정 (product_code UNIQUE → upsert) -->
|
||||||
|
<div class="erp-card cpg-form-card cpg-brule-add">
|
||||||
|
<div class="cpg-card-head">
|
||||||
|
<h2>박스 입수량 추가 / 수정</h2>
|
||||||
|
<span class="erp-muted">같은 제품코드는 덮어씁니다.</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/cupang/box-rules">
|
||||||
|
<input type="hidden" name="product_name_snapshot" id="brule-name-snap" />
|
||||||
|
<div class="cpg-brule-fields">
|
||||||
|
<div class="cpg-brule-row">
|
||||||
|
<label class="erp-field"><span>제품명 *</span>
|
||||||
|
<select class="erp-select cpg-brule-name" id="brule-name" required>
|
||||||
|
<option value="">— 제품명 선택 —</option>
|
||||||
|
{% for p in products %}
|
||||||
|
<option value="{{ p.product_code }}" data-name="{{ p.product_name }}">{{ p.product_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
<label class="erp-field"><span>제품코드</span>
|
||||||
|
<input class="erp-input cpg-brule-code" type="text" name="product_code" id="brule-code" required placeholder="자동" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="cpg-brule-row">
|
||||||
|
<label class="erp-field"><span>박스이름</span>
|
||||||
|
<input class="erp-input cpg-brule-box" type="text" name="box_name" value="쿠팡박스" /></label>
|
||||||
|
<label class="erp-field"><span>박스당 입수량 *</span>
|
||||||
|
<span class="cpg-upb-wrap">
|
||||||
|
<input class="erp-input cpg-brule-upb" type="number" name="units_per_box" min="1" required />
|
||||||
|
<span class="cpg-upb-unit">개</span>
|
||||||
|
</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="cpg-brule-row">
|
||||||
|
<label class="erp-field cpg-brule-memo-field"><span>메모</span>
|
||||||
|
<input class="erp-input cpg-brule-memo" type="text" name="memo" /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="erp-page-actions" style="margin-top:12px;">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary">저장</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% if not products %}
|
||||||
|
<p class="erp-muted"><a href="/cupang/products">설정에서 제품명을 먼저 등록</a>하면 드롭다운에 표시됩니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var sel = document.getElementById("brule-name");
|
||||||
|
var code = document.getElementById("brule-code");
|
||||||
|
var snap = document.getElementById("brule-name-snap");
|
||||||
|
if (!sel) return;
|
||||||
|
sel.addEventListener("change", function () {
|
||||||
|
var opt = sel.options[sel.selectedIndex];
|
||||||
|
code.value = sel.value;
|
||||||
|
snap.value = opt ? (opt.getAttribute("data-name") || "") : "";
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 오른쪽: 목록 -->
|
||||||
|
<div class="erp-card cpg-form-card cpg-brule-list">
|
||||||
|
<div class="cpg-card-head"><h2>입수량 규칙 ({{ box_rules|length }})</h2></div>
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>제품명</th><th>제품코드</th><th>박스명</th><th>입수량</th><th>메모</th><th>동작</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in box_rules %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.product_name_snapshot or '—' }}</td>
|
||||||
|
<td>{{ r.product_code }}</td>
|
||||||
|
<td>{{ r.box_name }}</td>
|
||||||
|
<td>{{ r.units_per_box }}개</td>
|
||||||
|
<td>{{ r.memo or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/cupang/box-rules/{{ r.id }}/delete" class="cpg-inline-form"
|
||||||
|
onsubmit="return confirm('이 규칙을 삭제합니다. 계속할까요?');">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not box_rules %}
|
||||||
|
<tr><td colspan="6" class="erp-muted">등록된 입수량 규칙이 없습니다.</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /cpg-brule-layout -->
|
||||||
|
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="cpg">
|
||||||
|
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cpg-center-layout">
|
||||||
|
|
||||||
|
<!-- 왼쪽: 입고센터 추가 -->
|
||||||
|
<div class="erp-card cpg-form-card cpg-center-add">
|
||||||
|
<h2 class="cpg-center-add-title">입고센터 추가</h2>
|
||||||
|
<form method="post" action="/cupang/centers" class="cpg-inline-form">
|
||||||
|
<input class="erp-input" type="text" name="name" placeholder="센터명 (예: 대구3)" required style="flex:1 1 auto;min-width:0" />
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary">추가</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 오른쪽: 센터 목록 (높이 900 고정, 내부 스크롤) -->
|
||||||
|
<div class="erp-card cpg-form-card cpg-center-listcard">
|
||||||
|
<div class="cpg-card-head">
|
||||||
|
<h2>센터 목록 ({{ centers|length }})</h2>
|
||||||
|
<span class="erp-muted">사용 중 센터는 삭제 시 비활성화됩니다.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cpg-center-list">
|
||||||
|
{% for c in centers %}
|
||||||
|
<div class="cpg-center-row {% if not c.active %}is-inactive{% endif %}">
|
||||||
|
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-center-edit">
|
||||||
|
<input class="erp-input cpg-center-name" type="text" name="name" value="{{ c.name }}" />
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline cpg-btn-sm" title="이름 저장">저장</button>
|
||||||
|
</form>
|
||||||
|
<span class="cpg-center-state">
|
||||||
|
{% if c.in_use %}<span class="erp-badge erp-badge-inverse cpg-mini">사용중</span>{% endif %}
|
||||||
|
{% if c.active %}<span class="erp-badge erp-badge-success cpg-mini">활성</span>
|
||||||
|
{% else %}<span class="erp-badge erp-badge-neutral cpg-mini">비활성</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="cpg-center-act">
|
||||||
|
{% if c.active %}
|
||||||
|
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-inline-form">
|
||||||
|
<input type="hidden" name="active" value="0" />
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline cpg-btn-sm">비활성</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-inline-form">
|
||||||
|
<input type="hidden" name="active" value="1" />
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline cpg-btn-sm">활성</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/cupang/centers/{{ c.id }}/delete" class="cpg-inline-form"
|
||||||
|
onsubmit="return confirm('{% if c.in_use %}사용 중 → 비활성화됩니다.{% else %}삭제합니다.{% endif %} 계속?');">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-danger cpg-btn-sm">삭제</button>
|
||||||
|
</form>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="cpg">
|
||||||
|
|
||||||
|
<div class="erp-page-actions cpg-actions">
|
||||||
|
<a class="erp-btn erp-btn-primary" href="/cupang/?date={{ shipment.ship_date }}">◀◀ 달력</a>
|
||||||
|
|
||||||
|
<form method="post" action="/cupang/{{ shipment.id }}/delete" class="cpg-inline-form"
|
||||||
|
onsubmit="return confirm('이 출고 묶음을 취소 처리합니다(상태=취소). 계속할까요?');">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline">취소 처리</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/cupang/{{ shipment.id }}/hard-delete" class="cpg-inline-form"
|
||||||
|
onsubmit="return confirm('이 출고를 완전 삭제합니다(복구 불가, 품목 포함). 계속할까요?');">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a class="erp-btn erp-btn-primary cpg-push-right" href="/cupang/{{ shipment.id }}/edit">수정</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 헤더 -->
|
||||||
|
<div class="erp-card cpg-form-card">
|
||||||
|
<div class="cpg-card-head">
|
||||||
|
<h2>출고 #{{ shipment.id }}
|
||||||
|
{% set badge = 'erp-badge-neutral' %}
|
||||||
|
{% if shipment.status == '출고완료' %}{% set badge = 'erp-badge-inverse' %}
|
||||||
|
{% elif shipment.status == '센터입고완료' %}{% set badge = 'erp-badge-success' %}
|
||||||
|
{% elif shipment.status == '취소' %}{% set badge = 'erp-badge-danger' %}{% endif %}
|
||||||
|
<span class="erp-badge {{ badge }}">{{ shipment.status }}</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<dl class="cpg-detail-grid">
|
||||||
|
<div><dt>작성일</dt><dd>{{ shipment.document_date }}</dd></div>
|
||||||
|
<div><dt>출고일</dt><dd>{{ shipment.ship_date }}</dd></div>
|
||||||
|
<div><dt>센터입고일</dt><dd>{{ shipment.center_arrival_date }}</dd></div>
|
||||||
|
<div><dt>입고센터</dt><dd>{{ shipment.center_name_snapshot or '—' }}</dd></div>
|
||||||
|
<div><dt>출고방식</dt><dd>{{ shipment.ship_method }}</dd></div>
|
||||||
|
<div><dt>작업자</dt><dd>{{ shipment.worker or '—' }}</dd></div>
|
||||||
|
<div class="cpg-full"><dt>출고/박스 요약</dt><dd>{{ shipment.outbound_summary or '—' }}</dd></div>
|
||||||
|
<div class="cpg-full"><dt>메모</dt><dd>{{ shipment.memo or '—' }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 라인 -->
|
||||||
|
<div class="erp-card cpg-form-card">
|
||||||
|
<div class="cpg-card-head"><h2>품목 ({{ shipment.lines|length }})</h2></div>
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>#</th><th>제품코드</th><th>제품명</th><th>수량</th>
|
||||||
|
<th>입수량</th><th>필요박스</th><th>잔량</th><th>수동보정</th><th>메모</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ln in shipment.lines %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ ln.line_no }}</td>
|
||||||
|
<td>{{ ln.product_code }}</td>
|
||||||
|
<td>{{ ln.product_name_snapshot }}</td>
|
||||||
|
<td>{{ ln.quantity }}</td>
|
||||||
|
<td>{{ ln.units_per_box if ln.units_per_box else '미설정' }}</td>
|
||||||
|
<td>{{ ln.calculated_boxes if ln.calculated_boxes is not none else '—' }}</td>
|
||||||
|
<td>{{ ln.remainder_units if ln.remainder_units is not none else '—' }}</td>
|
||||||
|
<td>{{ ln.manual_box_text or '—' }}</td>
|
||||||
|
<td>{{ ln.memo or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="cpg">
|
||||||
|
|
||||||
|
{% set action = '/cupang/new' if mode == 'new' else '/cupang/' ~ shipment.id ~ '/edit' %}
|
||||||
|
<form id="cpg-form" method="post" action="{{ action }}">
|
||||||
|
<input type="hidden" name="lines_json" id="cpg-lines-json" value="[]" />
|
||||||
|
|
||||||
|
<div class="cpg-form-2col">
|
||||||
|
|
||||||
|
<!-- ── 공통 헤더 (왼쪽) ── -->
|
||||||
|
<div class="erp-card cpg-form-card cpg-form-head">
|
||||||
|
<div class="cpg-card-head"><h2>공통 헤더</h2></div>
|
||||||
|
<div class="cpg-header-grid">
|
||||||
|
<label class="erp-field"><span>작성일 *</span>
|
||||||
|
<input class="erp-input" type="date" name="document_date" required
|
||||||
|
value="{{ shipment.document_date if shipment else default_date }}" /></label>
|
||||||
|
<label class="erp-field"><span>출고일 *</span>
|
||||||
|
<input class="erp-input" type="date" name="ship_date" required
|
||||||
|
value="{{ shipment.ship_date if shipment else default_date }}" /></label>
|
||||||
|
<label class="erp-field"><span>센터입고일 *</span>
|
||||||
|
<input class="erp-input" type="date" name="center_arrival_date" required
|
||||||
|
value="{{ shipment.center_arrival_date if shipment else default_date }}" /></label>
|
||||||
|
|
||||||
|
<label class="erp-field"><span>입고센터</span>
|
||||||
|
<select class="erp-select" name="center_id" id="cpg-center-select">
|
||||||
|
<option value="">— 선택 —</option>
|
||||||
|
{% for c in centers %}
|
||||||
|
<option value="{{ c.id }}" data-name="{{ c.name }}"
|
||||||
|
{% if shipment and shipment.center_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
<!-- 센터 스냅샷(자유 입력 허용: 과거 명칭 보존/센터 미등록 시) -->
|
||||||
|
<input type="hidden" name="center_name_snapshot" id="cpg-center-name"
|
||||||
|
value="{{ shipment.center_name_snapshot if shipment else '' }}" />
|
||||||
|
|
||||||
|
<label class="erp-field"><span>출고방식</span>
|
||||||
|
<select class="erp-select" name="ship_method">
|
||||||
|
{% for m in ship_methods %}
|
||||||
|
<option value="{{ m }}" {% if shipment and shipment.ship_method == m %}selected{% endif %}>{{ m }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
|
||||||
|
<label class="erp-field"><span>작업자</span>
|
||||||
|
<input class="erp-input" type="text" name="worker"
|
||||||
|
value="{{ shipment.worker if shipment else '' }}" /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="erp-field cpg-full"><span>출고/박스 요약 (수동 보정 메모)</span>
|
||||||
|
<input class="erp-input" type="text" name="outbound_summary"
|
||||||
|
placeholder="예: 쿠팡박스 50, 6호상자 1, (50번 박스)"
|
||||||
|
value="{{ shipment.outbound_summary if shipment else '' }}" /></label>
|
||||||
|
<label class="erp-field cpg-full"><span>메모</span>
|
||||||
|
<textarea class="erp-input" name="memo" rows="2">{{ shipment.memo if shipment else '' }}</textarea></label>
|
||||||
|
|
||||||
|
<div class="erp-page-actions cpg-form-actions">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary">저장</button>
|
||||||
|
{% if mode == 'edit' %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/cupang/{{ shipment.id }}">취소</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/cupang/">취소</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 품목 라인 (오른쪽) ── -->
|
||||||
|
<div class="erp-card cpg-form-card cpg-form-lines">
|
||||||
|
<div class="cpg-card-head cpg-lines-head">
|
||||||
|
<h2>품목 라인</h2>
|
||||||
|
<span class="erp-muted">
|
||||||
|
제품명 선택 시 제품코드 자동 입력. 수량 입력 시 박스 수 자동 계산.
|
||||||
|
{% if not products %}<a href="/cupang/products">설정에서 제품명 먼저 등록</a>{% endif %}
|
||||||
|
</span>
|
||||||
|
<div class="cpg-lines-btns">
|
||||||
|
<button type="button" class="erp-btn erp-btn-outline" id="cpg-add-line">+ 라인 추가</button>
|
||||||
|
<button type="button" class="erp-btn erp-btn-danger" id="cpg-del-line">선택 라인 삭제</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-table-wrap cpg-lines-scroll">
|
||||||
|
<table class="erp-table cpg-lines">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="cpg-check-col"><input type="checkbox" id="cpg-check-all" title="전체 선택" /></th>
|
||||||
|
<th>제품명</th><th>제품코드</th><th>수량</th>
|
||||||
|
<th>입수량</th><th>박스 계산</th><th>라인메모</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="cpg-lines-body"><!-- JS 렌더 --></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /cpg-form-2col -->
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script type="application/json" id="cpg-init-lines">
|
||||||
|
{% if shipment and shipment.lines %}{{ shipment.lines | tojson }}{% else %}[]{% endif %}
|
||||||
|
</script>
|
||||||
|
<script type="application/json" id="cpg-box-rules">
|
||||||
|
{{ box_rules | tojson }}
|
||||||
|
</script>
|
||||||
|
<script type="application/json" id="cpg-products">
|
||||||
|
{{ products | tojson }}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}<script src="/static/cupang.js?v=20260530p" defer></script>{% endblock %}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="cpg">
|
||||||
|
|
||||||
|
<!-- 페이지 액션 (달력 컬럼 폭에 맞춤: 신규 좌측 / 설정 달력 오른쪽 끝) -->
|
||||||
|
<div class="cpg-actions-grid">
|
||||||
|
<div class="cpg-actions-main">
|
||||||
|
<a class="erp-btn erp-btn-primary" href="/cupang/new">+ 신규 등록</a>
|
||||||
|
<span class="cpg-settings-btns">
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/cupang/products">제품명 설정</a>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/cupang/centers">입고센터 관리</a>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">박스 입수량 설정</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="cpg-actions-spacer"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cpg-layout">
|
||||||
|
<!-- ── 왼쪽: 큰 월간 달력 ── -->
|
||||||
|
<div class="erp-card cpg-cal-card">
|
||||||
|
<div class="cpg-cal-head">
|
||||||
|
<a class="erp-btn erp-btn-outline cpg-nav-btn"
|
||||||
|
href="/cupang/?year={{ prev_y }}&month={{ prev_m }}">‹</a>
|
||||||
|
<h2 class="cpg-cal-title">{{ year }}년 {{ month }}월</h2>
|
||||||
|
<a class="erp-btn erp-btn-outline cpg-nav-btn"
|
||||||
|
href="/cupang/?year={{ next_y }}&month={{ next_m }}">›</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cpg-cal-grid">
|
||||||
|
{% for wd in weekdays %}
|
||||||
|
<div class="cpg-cal-wd {% if loop.index0 == 0 %}cpg-sun{% elif loop.index0 == 6 %}cpg-sat{% endif %}">{{ wd }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for week in cal_weeks %}
|
||||||
|
{% for cell in week %}
|
||||||
|
<a class="cpg-cal-cell
|
||||||
|
{% if not cell.in_month %}cpg-out{% endif %}
|
||||||
|
{% if cell.is_today %}cpg-today{% endif %}
|
||||||
|
{% if cell.is_selected %}cpg-selected{% endif %}
|
||||||
|
{% if cell.is_sunday or cell.is_holiday %}cpg-red{% elif cell.is_saturday %}cpg-blue{% endif %}"
|
||||||
|
href="/cupang/?year={{ year }}&month={{ month }}&date={{ cell.date }}">
|
||||||
|
<span class="cpg-cal-day">{{ cell.day }}</span>
|
||||||
|
<span class="cpg-cal-badges">
|
||||||
|
{% if cell.counts.document %}<span class="erp-badge erp-badge-neutral cpg-mini">작성 {{ cell.counts.document }}</span>{% endif %}
|
||||||
|
{% if cell.counts.ship %}<span class="erp-badge erp-badge-inverse cpg-mini">출고 {{ cell.counts.ship }}</span>{% endif %}
|
||||||
|
{% if cell.counts.arrival %}<span class="erp-badge erp-badge-success cpg-mini">입고 {{ cell.counts.arrival }}</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 오른쪽: 선택일 출고 묶음 리스트 ── -->
|
||||||
|
<div class="erp-card cpg-list-card">
|
||||||
|
<div class="cpg-list-head">
|
||||||
|
<h2>{{ selected_date }} 출고</h2>
|
||||||
|
<span class="erp-muted">{{ sel_shipments|length }}건</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if sel_shipments %}
|
||||||
|
<ul class="cpg-list">
|
||||||
|
{% for s in sel_shipments %}
|
||||||
|
<li class="cpg-list-item">
|
||||||
|
<a href="/cupang/{{ s.id }}" class="cpg-list-link cpg-hover-item"
|
||||||
|
data-items='{{ s.tip_items | tojson }}'>
|
||||||
|
<div class="cpg-list-top">
|
||||||
|
<strong>{{ s.center_name_snapshot or '센터 미지정' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="cpg-list-meta erp-muted">
|
||||||
|
출고 {{ s.ship_date }} · 입고 {{ s.center_arrival_date }} · {{ s.ship_method }}
|
||||||
|
{% if s.outbound_summary %}<br>{{ s.outbound_summary }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="erp-muted cpg-empty">선택한 날짜의 출고 묶음이 없습니다.
|
||||||
|
<a href="/cupang/new">신규 등록</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- hover 툴팁 (마우스 따라다님, 커서 왼쪽) -->
|
||||||
|
<div id="cpg-hover-tip" class="cpg-hover-tip" hidden></div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var tip = document.getElementById("cpg-hover-tip");
|
||||||
|
if (!tip) return;
|
||||||
|
|
||||||
|
function buildHtml(items) {
|
||||||
|
if (!items || !items.length) return '<div class="cpg-tip-empty">품목 없음</div>';
|
||||||
|
var html = "";
|
||||||
|
items.forEach(function (it) {
|
||||||
|
var d1 = document.createElement("div");
|
||||||
|
d1.className = "cpg-tip-row";
|
||||||
|
var n = document.createElement("span"); n.className = "cpg-tip-name"; n.textContent = it.name || "";
|
||||||
|
var q = document.createElement("span"); q.className = "cpg-tip-qty"; q.textContent = (it.qty != null ? it.qty : 0) + "개";
|
||||||
|
d1.appendChild(n); d1.appendChild(q);
|
||||||
|
html += d1.outerHTML;
|
||||||
|
});
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(e) {
|
||||||
|
// 커서 왼쪽에 표시
|
||||||
|
var w = tip.offsetWidth || 200;
|
||||||
|
var x = e.clientX - w - 14;
|
||||||
|
if (x < 6) x = e.clientX + 16; // 화면 왼쪽 벗어나면 오른쪽으로
|
||||||
|
var y = e.clientY + 12;
|
||||||
|
var maxY = window.innerHeight - tip.offsetHeight - 8;
|
||||||
|
if (y > maxY) y = maxY;
|
||||||
|
tip.style.left = x + "px";
|
||||||
|
tip.style.top = y + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(".cpg-hover-item").forEach(function (el) {
|
||||||
|
el.addEventListener("mouseenter", function (e) {
|
||||||
|
var items = [];
|
||||||
|
try { items = JSON.parse(el.getAttribute("data-items") || "[]"); } catch (_) {}
|
||||||
|
tip.innerHTML = buildHtml(items);
|
||||||
|
tip.hidden = false;
|
||||||
|
move(e);
|
||||||
|
});
|
||||||
|
el.addEventListener("mousemove", move);
|
||||||
|
el.addEventListener("mouseleave", function () { tip.hidden = true; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="cpg">
|
||||||
|
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cpg-prod-layout">
|
||||||
|
|
||||||
|
<!-- ── 왼쪽: 미라네 주방 상품 목록 (다중선택 → 등록) ── -->
|
||||||
|
<div class="erp-card cpg-form-card cpg-prod-left">
|
||||||
|
<div class="cpg-card-head">
|
||||||
|
<h2>미라네 주방 상품</h2>
|
||||||
|
<span class="erp-muted">
|
||||||
|
{% if search_enabled %}선택(다중) 후 등록. 이미 등록된 항목은 진한 회색.{% else %}
|
||||||
|
검색 비활성: {{ search_reason }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if search_enabled %}
|
||||||
|
<div class="cpg-inline-form" style="margin-bottom:10px;">
|
||||||
|
<input class="erp-input" type="text" id="cpg-prod-q" placeholder="이름/코드 필터" style="min-width:200px" />
|
||||||
|
<button type="button" class="erp-btn erp-btn-primary" id="cpg-prod-register">선택 등록</button>
|
||||||
|
</div>
|
||||||
|
<div id="cpg-src-list" class="cpg-src-list"><p class="erp-muted">불러오는 중…</p></div>
|
||||||
|
{% else %}
|
||||||
|
<p class="erp-muted">ITEMCODE_DB_URL / ITEMCODE_SEARCH_SQL 설정 후 사용 가능합니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 오른쪽: 등록된 제품명 ── -->
|
||||||
|
<div class="erp-card cpg-form-card cpg-prod-right">
|
||||||
|
<div class="cpg-card-head"><h2>등록된 제품명 ({{ products|length }})</h2>
|
||||||
|
<span class="erp-muted">폼의 제품명 드롭다운에 노출</span></div>
|
||||||
|
<div class="erp-table-wrap cpg-reg-scroll">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead><tr><th>제품명</th><th>제품코드</th><th>상태</th><th>동작</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in products %}
|
||||||
|
<tr {% if not p.active %}style="opacity:.55"{% endif %}>
|
||||||
|
<td>{{ p.product_name }}</td>
|
||||||
|
<td>{{ p.product_code }}</td>
|
||||||
|
<td>{% if p.active %}<span class="erp-badge erp-badge-success">활성</span>{% else %}<span class="erp-badge erp-badge-neutral">비활성</span>{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
<div class="cpg-row-actions">
|
||||||
|
{% if p.active %}
|
||||||
|
<form method="post" action="/cupang/products/{{ p.id }}/active" class="cpg-inline-form">
|
||||||
|
<input type="hidden" name="active" value="0" />
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline">비활성</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="/cupang/products/{{ p.id }}/active" class="cpg-inline-form">
|
||||||
|
<input type="hidden" name="active" value="1" />
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline">활성</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/cupang/products/{{ p.id }}/delete" class="cpg-inline-form"
|
||||||
|
onsubmit="return confirm('완전 삭제합니다. 계속할까요?');">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not products %}
|
||||||
|
<tr><td colspan="4" class="erp-muted">등록된 제품명이 없습니다. 왼쪽에서 선택해 등록하세요.</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if search_enabled %}
|
||||||
|
<script type="application/json" id="cpg-registered">{{ registered_codes | tojson }}</script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var listBox = document.getElementById("cpg-src-list");
|
||||||
|
var filter = document.getElementById("cpg-prod-q");
|
||||||
|
var regBtn = document.getElementById("cpg-prod-register");
|
||||||
|
var registered = {};
|
||||||
|
try { (JSON.parse(document.getElementById("cpg-registered").textContent || "[]")).forEach(function(c){ registered[c]=true; }); } catch(e){}
|
||||||
|
var all = [];
|
||||||
|
var selected = {};
|
||||||
|
|
||||||
|
function esc(s){ var d=document.createElement("div"); d.textContent=s||""; return d.innerHTML; }
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var term = (filter.value || "").trim().toLowerCase();
|
||||||
|
var rows = all.filter(function (it) {
|
||||||
|
if (!term) return true;
|
||||||
|
return (it.code + " " + it.name).toLowerCase().indexOf(term) >= 0;
|
||||||
|
});
|
||||||
|
if (!rows.length) { listBox.innerHTML = '<p class="erp-muted">결과 없음</p>'; return; }
|
||||||
|
var html = "";
|
||||||
|
rows.forEach(function (it) {
|
||||||
|
var cls = "cpg-src-item";
|
||||||
|
if (registered[it.code]) cls += " cpg-registered";
|
||||||
|
if (selected[it.code]) cls += " is-selected";
|
||||||
|
html += '<div class="' + cls + '" data-code="' + esc(it.code) + '">' +
|
||||||
|
'<span class="cpg-src-name">' + esc(it.name) + '</span>' +
|
||||||
|
'<span class="cpg-src-code">' + esc(it.code) + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
listBox.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
listBox.addEventListener("click", function (e) {
|
||||||
|
var item = e.target.closest(".cpg-src-item");
|
||||||
|
if (!item) return;
|
||||||
|
var code = item.getAttribute("data-code");
|
||||||
|
if (selected[code]) { delete selected[code]; item.classList.remove("is-selected"); }
|
||||||
|
else { selected[code] = true; item.classList.add("is-selected"); }
|
||||||
|
});
|
||||||
|
|
||||||
|
filter.addEventListener("input", render);
|
||||||
|
|
||||||
|
regBtn.addEventListener("click", function () {
|
||||||
|
var items = all.filter(function (it) { return selected[it.code]; })
|
||||||
|
.map(function (it) { return { code: it.code, name: it.name }; });
|
||||||
|
if (!items.length) { alert("등록할 상품을 선택하세요."); return; }
|
||||||
|
regBtn.disabled = true;
|
||||||
|
fetch("/cupang/products/bulk", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ items: items })
|
||||||
|
}).then(function (r) { return r.json(); })
|
||||||
|
.then(function () { location.reload(); })
|
||||||
|
.catch(function () { regBtn.disabled = false; alert("등록 실패"); });
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch("/cupang/api/products/all")
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
all = (data && data.results) || [];
|
||||||
|
if (!all.length) {
|
||||||
|
var msg = "itemcode_db 결과 없음";
|
||||||
|
if (data && data.error) { msg += " — 조회 오류: " + esc(data.error); }
|
||||||
|
else if (data && !data.enabled && data.reason) { msg += " — " + esc(data.reason); }
|
||||||
|
else { msg += " (테이블이 비었거나 검색 SQL 조건 불일치)"; }
|
||||||
|
listBox.innerHTML = '<p class="erp-muted">' + msg + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
})
|
||||||
|
.catch(function () { listBox.innerHTML = '<p class="erp-muted">목록 로드 실패</p>'; });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"}
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
// 매칭된 부분만 강조(이미 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 & 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 %}
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""개인경비(expense) 모듈.
|
||||||
|
|
||||||
|
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
|
||||||
|
- 라우터: `router.py` (FastAPI APIRouter, prefix=/expense)
|
||||||
|
- 저장소: `store.py` (DATA_DIR/expense.json, 향후 expense_db 후보)
|
||||||
|
- 템플릿: `templates/expense/index.html`
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .categories import DEFAULT_CATEGORIES, CategoryStore
|
||||||
|
from .router import router
|
||||||
|
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"router",
|
||||||
|
"ExpenseStore",
|
||||||
|
"CategoryStore",
|
||||||
|
"DEFAULT_CATEGORIES",
|
||||||
|
"CATEGORIES",
|
||||||
|
"METHODS",
|
||||||
|
"STATUSES",
|
||||||
|
"build_expense_store",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_expense_store(*, dsn: str | None, json_path: Path) -> Any:
|
||||||
|
"""env 의 EXPENSE_DB_URL 이 있으면 DB 저장소, 없으면 JSON 저장소.
|
||||||
|
|
||||||
|
DB 저장소 실패(드라이버 미설치/접속 실패) 시 예외를 그대로 전파한다 —
|
||||||
|
의도치 않게 JSON 으로 폴백해 운영 데이터가 갈라지는 것을 막기 위함.
|
||||||
|
"""
|
||||||
|
if dsn:
|
||||||
|
from .db import ExpenseDBStore # 지연 import (개발 환경 deps 없을 수 있음)
|
||||||
|
|
||||||
|
return ExpenseDBStore(dsn)
|
||||||
|
return ExpenseStore(json_path)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""개인경비 분류(category) 설정 저장소.
|
||||||
|
|
||||||
|
- 저장 위치: DATA_DIR/expense_categories.json
|
||||||
|
- 관리자만 추가/삭제. 저장 즉시 사용자 등록 폼/집계에 반영.
|
||||||
|
- JSON 파일 기반 — expense_db(PostgreSQL) 모드와 무관하게 동작(설정값이라
|
||||||
|
트랜잭션 데이터와 분리). DB 스키마 변경(superuser SQL) 불필요.
|
||||||
|
- 동시성: ExpenseStore 와 동일 패턴(threading.Lock + temp→rename 원자적 쓰기).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# 최초 1회 시드. 기존 하드코딩 CATEGORIES 와 동일.
|
||||||
|
DEFAULT_CATEGORIES: tuple[str, ...] = (
|
||||||
|
"식대", "교통", "숙박", "비품", "접대", "통신", "기타",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryStore:
|
||||||
|
def __init__(self, path: Path):
|
||||||
|
self._path = path
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not self._path.exists():
|
||||||
|
self._write_atomic({"categories": list(DEFAULT_CATEGORIES)})
|
||||||
|
|
||||||
|
def _read(self) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with self._path.open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
data = {}
|
||||||
|
cats = data.get("categories")
|
||||||
|
if not isinstance(cats, list) or not cats:
|
||||||
|
data["categories"] = list(DEFAULT_CATEGORIES)
|
||||||
|
else:
|
||||||
|
# 문자열만, 공백 제거, 중복 제거(순서 유지)
|
||||||
|
seen: set[str] = set()
|
||||||
|
clean: list[str] = []
|
||||||
|
for c in cats:
|
||||||
|
name = str(c).strip()
|
||||||
|
if name and name not in seen:
|
||||||
|
seen.add(name)
|
||||||
|
clean.append(name)
|
||||||
|
data["categories"] = clean or list(DEFAULT_CATEGORIES)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _write_atomic(self, data: dict[str, Any]) -> None:
|
||||||
|
fd, tmp = tempfile.mkstemp(
|
||||||
|
prefix=".expense_categories.", suffix=".json.tmp",
|
||||||
|
dir=str(self._path.parent),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp, self._path)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
def list(self) -> list[str]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._read()["categories"])
|
||||||
|
|
||||||
|
def add(self, name: str) -> list[str]:
|
||||||
|
name = str(name or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("분류명을 입력하세요.")
|
||||||
|
if len(name) > 30:
|
||||||
|
raise ValueError("분류명은 30자 이하로 입력하세요.")
|
||||||
|
with self._lock:
|
||||||
|
data = self._read()
|
||||||
|
if name in data["categories"]:
|
||||||
|
raise ValueError(f"이미 존재하는 분류입니다: {name}")
|
||||||
|
data["categories"].append(name)
|
||||||
|
self._write_atomic(data)
|
||||||
|
return list(data["categories"])
|
||||||
|
|
||||||
|
def delete(self, name: str) -> list[str]:
|
||||||
|
name = str(name or "").strip()
|
||||||
|
with self._lock:
|
||||||
|
data = self._read()
|
||||||
|
if name not in data["categories"]:
|
||||||
|
raise KeyError(name)
|
||||||
|
if len(data["categories"]) <= 1:
|
||||||
|
raise ValueError("분류는 최소 1개 이상이어야 합니다.")
|
||||||
|
data["categories"] = [c for c in data["categories"] if c != name]
|
||||||
|
self._write_atomic(data)
|
||||||
|
return list(data["categories"])
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
"""expense_db PostgreSQL 저장소.
|
||||||
|
|
||||||
|
JSON 저장소(`ExpenseStore`)와 같은 인터페이스를 제공하여, 라우터 코드를
|
||||||
|
바꾸지 않고도 교체할 수 있다.
|
||||||
|
|
||||||
|
- 드라이버: psycopg 3 (`psycopg[binary,pool]`)
|
||||||
|
- 연결 정보: 환경변수 `EXPENSE_DB_URL` (예: postgresql://user:pwd@host:5432/expense_db)
|
||||||
|
- 스키마: `scripts/sql/expense_db_init.sql` 로 사전 초기화한다. 본 클래스는
|
||||||
|
앱 부팅 시 `CREATE TABLE IF NOT EXISTS`로 보강만 한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
|
from app.timezone import KST
|
||||||
|
|
||||||
|
from .store import APPROVED_STATUSES, CATEGORIES, METHODS, STATUSES
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseDBStore:
|
||||||
|
"""`ExpenseStore` 와 동일한 메서드 시그니처.
|
||||||
|
|
||||||
|
스키마(테이블/인덱스/트리거)는 앱이 직접 만들지 않는다.
|
||||||
|
`scripts/sql/expense_db_init.sql` 과 `expense_db_002_*.sql` 을 통해
|
||||||
|
superuser 가 사전 적용한다. 앱 계정(expense_app)은 SELECT/INSERT/UPDATE/DELETE
|
||||||
|
권한만 받기 때문에 PostgreSQL 15+ 의 strict public-schema 정책과 충돌하지 않음.
|
||||||
|
|
||||||
|
연결 풀은 lazy open — 부팅 시점에 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||||
|
self._pool = ConnectionPool(
|
||||||
|
conninfo=dsn,
|
||||||
|
min_size=min_size,
|
||||||
|
max_size=max_size,
|
||||||
|
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||||
|
open=False,
|
||||||
|
)
|
||||||
|
self._pool.open(wait=False)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._pool.close()
|
||||||
|
|
||||||
|
# ── 조회 ──
|
||||||
|
def list_for(self, email: str) -> list[dict[str, Any]]:
|
||||||
|
email = email.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM expense_items WHERE owner = %s "
|
||||||
|
"ORDER BY spent_at DESC, created_at DESC",
|
||||||
|
(email,),
|
||||||
|
).fetchall()
|
||||||
|
return [self._serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def list_all(self) -> list[dict[str, Any]]:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM expense_items ORDER BY created_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [self._serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None:
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM expense_items WHERE id = %s AND owner = %s",
|
||||||
|
(item_id, owner),
|
||||||
|
).fetchone()
|
||||||
|
return self._serialize(row) if row else None
|
||||||
|
|
||||||
|
# ── 변경 ──
|
||||||
|
def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
norm = self._normalize(payload)
|
||||||
|
if not norm["spent_at"]:
|
||||||
|
raise ValueError("spent_at 필수")
|
||||||
|
status = (payload.get("status") or "작성중").strip()
|
||||||
|
item_id = uuid.uuid4().hex[:12]
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO expense_items
|
||||||
|
(id, owner, spent_at, category, method, merchant, amount, memo, status)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
item_id,
|
||||||
|
owner,
|
||||||
|
norm["spent_at"],
|
||||||
|
norm["category"],
|
||||||
|
norm["method"],
|
||||||
|
norm["merchant"],
|
||||||
|
norm["amount"],
|
||||||
|
norm["memo"],
|
||||||
|
status,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return self._serialize(row)
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self, *, item_id: str, owner: str, payload: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
norm = self._normalize(payload)
|
||||||
|
status = payload.get("status")
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
if status:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE expense_items
|
||||||
|
SET spent_at = %s, category = %s, method = %s,
|
||||||
|
merchant = %s, amount = %s, memo = %s, status = %s
|
||||||
|
WHERE id = %s AND owner = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
norm["spent_at"] or None,
|
||||||
|
norm["category"],
|
||||||
|
norm["method"],
|
||||||
|
norm["merchant"],
|
||||||
|
norm["amount"],
|
||||||
|
norm["memo"],
|
||||||
|
status,
|
||||||
|
item_id,
|
||||||
|
owner,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE expense_items
|
||||||
|
SET spent_at = %s, category = %s, method = %s,
|
||||||
|
merchant = %s, amount = %s, memo = %s
|
||||||
|
WHERE id = %s AND owner = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
norm["spent_at"] or None,
|
||||||
|
norm["category"],
|
||||||
|
norm["method"],
|
||||||
|
norm["merchant"],
|
||||||
|
norm["amount"],
|
||||||
|
norm["memo"],
|
||||||
|
item_id,
|
||||||
|
owner,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(item_id)
|
||||||
|
return self._serialize(row)
|
||||||
|
|
||||||
|
def delete(self, *, item_id: str, owner: str) -> None:
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM expense_items WHERE id = %s AND owner = %s",
|
||||||
|
(item_id, owner),
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise KeyError(item_id)
|
||||||
|
|
||||||
|
# ── 워크플로 ──
|
||||||
|
def submit(self, *, item_id: str, owner: str) -> dict[str, Any]:
|
||||||
|
return self._transition_owner(
|
||||||
|
item_id=item_id, owner=owner, from_status="작성중", to_status="제출"
|
||||||
|
)
|
||||||
|
|
||||||
|
def revert_to_draft(self, *, item_id: str, owner: str) -> dict[str, Any]:
|
||||||
|
"""반려 또는 제출 상태에서 본인이 작성중으로 되돌림."""
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE expense_items
|
||||||
|
SET status = '작성중',
|
||||||
|
reject_reason = NULL,
|
||||||
|
decided_at = NULL,
|
||||||
|
approver_email = NULL
|
||||||
|
WHERE id = %s AND owner = %s
|
||||||
|
AND status IN ('제출', '반려')
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(item_id, owner),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("작성중 으로 되돌릴 수 없는 상태입니다.")
|
||||||
|
return self._serialize(row)
|
||||||
|
|
||||||
|
def approve(self, *, item_id: str, approver_email: str) -> dict[str, Any]:
|
||||||
|
return self._transition_approver(
|
||||||
|
item_id=item_id,
|
||||||
|
approver_email=approver_email,
|
||||||
|
from_statuses=("제출",),
|
||||||
|
to_status="승인",
|
||||||
|
)
|
||||||
|
|
||||||
|
def reject(
|
||||||
|
self, *, item_id: str, approver_email: str, reason: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not reason.strip():
|
||||||
|
raise ValueError("반려 사유 필수")
|
||||||
|
approver_email = approver_email.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE expense_items
|
||||||
|
SET status = '반려',
|
||||||
|
approver_email = %s,
|
||||||
|
decided_at = now(),
|
||||||
|
reject_reason = %s
|
||||||
|
WHERE id = %s AND status = '제출'
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(approver_email, reason.strip(), item_id),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("제출 상태가 아니거나 항목 없음")
|
||||||
|
return self._serialize(row)
|
||||||
|
|
||||||
|
def settle(self, *, item_id: str, approver_email: str) -> dict[str, Any]:
|
||||||
|
return self._transition_approver(
|
||||||
|
item_id=item_id,
|
||||||
|
approver_email=approver_email,
|
||||||
|
from_statuses=("승인",),
|
||||||
|
to_status="정산완료",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _transition_owner(
|
||||||
|
self, *, item_id: str, owner: str, from_status: str, to_status: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE expense_items
|
||||||
|
SET status = %s
|
||||||
|
WHERE id = %s AND owner = %s AND status = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(to_status, item_id, owner, from_status),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"전이 불가: {from_status} → {to_status}")
|
||||||
|
return self._serialize(row)
|
||||||
|
|
||||||
|
def _transition_approver(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
item_id: str,
|
||||||
|
approver_email: str,
|
||||||
|
from_statuses: tuple[str, ...],
|
||||||
|
to_status: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
approver_email = approver_email.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE expense_items
|
||||||
|
SET status = %s,
|
||||||
|
approver_email = %s,
|
||||||
|
decided_at = now()
|
||||||
|
WHERE id = %s AND status = ANY(%s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(to_status, approver_email, item_id, list(from_statuses)),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"전이 불가 → {to_status}")
|
||||||
|
return self._serialize(row)
|
||||||
|
|
||||||
|
# ── 승인자 대기열 ──
|
||||||
|
def list_pending_approval(self) -> list[dict[str, Any]]:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM expense_items WHERE status = '제출' "
|
||||||
|
"ORDER BY created_at ASC"
|
||||||
|
).fetchall()
|
||||||
|
return [self._serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def get_any(self, *, item_id: str) -> dict[str, Any] | None:
|
||||||
|
"""승인자/관리자용 — owner 무시하고 단일 조회."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM expense_items WHERE id = %s", (item_id,)
|
||||||
|
).fetchone()
|
||||||
|
return self._serialize(row) if row else None
|
||||||
|
|
||||||
|
# ── 첨부 ──
|
||||||
|
def add_attachment(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
item_id: str,
|
||||||
|
owner: str,
|
||||||
|
kind: str,
|
||||||
|
filename: str,
|
||||||
|
stored_path: str,
|
||||||
|
content_type: str,
|
||||||
|
size_bytes: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if kind not in ("receipt", "other"):
|
||||||
|
raise ValueError("kind 는 receipt|other")
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
att_id = uuid.uuid4().hex[:12]
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO expense_attachments
|
||||||
|
(id, item_id, owner, kind, filename, stored_path,
|
||||||
|
content_type, size_bytes)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
att_id,
|
||||||
|
item_id,
|
||||||
|
owner,
|
||||||
|
kind,
|
||||||
|
filename,
|
||||||
|
stored_path,
|
||||||
|
content_type,
|
||||||
|
size_bytes,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return self._att_serialize(row)
|
||||||
|
|
||||||
|
def list_attachments(self, *, item_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM expense_attachments WHERE item_id = %s "
|
||||||
|
"ORDER BY uploaded_at ASC",
|
||||||
|
(item_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [self._att_serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def get_attachment(self, *, att_id: str) -> dict[str, Any] | None:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM expense_attachments WHERE id = %s", (att_id,)
|
||||||
|
).fetchone()
|
||||||
|
return self._att_serialize(row) if row else None
|
||||||
|
|
||||||
|
def delete_attachment(self, *, att_id: str, owner: str) -> dict[str, Any]:
|
||||||
|
"""삭제된 행 반환 (파일 정리용 stored_path 포함)."""
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"DELETE FROM expense_attachments "
|
||||||
|
"WHERE id = %s AND owner = %s RETURNING *",
|
||||||
|
(att_id, owner),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(att_id)
|
||||||
|
return self._att_serialize(row)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _att_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
out = dict(row)
|
||||||
|
v = out.get("uploaded_at")
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
out["uploaded_at"] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||||
|
out["size_bytes"] = int(out.get("size_bytes", 0))
|
||||||
|
return out
|
||||||
|
|
||||||
|
# ── 승인완료 (월별, 전 직원) ──
|
||||||
|
def list_approved(self, *, year: int, month: int) -> list[dict[str, Any]]:
|
||||||
|
"""해당 월(spent_at)의 승인완료 항목 — 전 직원."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM expense_items
|
||||||
|
WHERE status = ANY(%s)
|
||||||
|
AND EXTRACT(YEAR FROM spent_at) = %s
|
||||||
|
AND EXTRACT(MONTH FROM spent_at) = %s
|
||||||
|
ORDER BY owner ASC, spent_at ASC, created_at ASC
|
||||||
|
""",
|
||||||
|
(list(APPROVED_STATUSES), year, month),
|
||||||
|
).fetchall()
|
||||||
|
return [self._serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def approved_attachments(
|
||||||
|
self, *, year: int, month: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""해당 월 승인완료 항목의 첨부 — zip 다운로드용.
|
||||||
|
|
||||||
|
uploaded_at(datetime), spent_at(date) 를 가공 없이 반환(파일명 생성용).
|
||||||
|
"""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT a.id, a.item_id, a.owner, a.kind, a.filename,
|
||||||
|
a.stored_path, a.content_type, a.uploaded_at,
|
||||||
|
i.spent_at, i.category, i.method, i.amount, i.merchant
|
||||||
|
FROM expense_attachments a
|
||||||
|
JOIN expense_items i ON i.id = a.item_id
|
||||||
|
WHERE i.status = ANY(%s)
|
||||||
|
AND EXTRACT(YEAR FROM i.spent_at) = %s
|
||||||
|
AND EXTRACT(MONTH FROM i.spent_at) = %s
|
||||||
|
ORDER BY a.owner ASC, a.uploaded_at ASC
|
||||||
|
""",
|
||||||
|
(list(APPROVED_STATUSES), year, month),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
# ── 집계 (월별) ──
|
||||||
|
def monthly_summary(
|
||||||
|
self, *, email: str, year: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
email = email.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT to_char(spent_at, 'YYYY-MM') AS month,
|
||||||
|
category,
|
||||||
|
COUNT(*) AS cnt,
|
||||||
|
COALESCE(SUM(amount), 0) AS total
|
||||||
|
FROM expense_items
|
||||||
|
WHERE owner = %s AND EXTRACT(YEAR FROM spent_at) = %s
|
||||||
|
GROUP BY 1, 2
|
||||||
|
ORDER BY 1, 2
|
||||||
|
""",
|
||||||
|
(email, year),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"month": r["month"],
|
||||||
|
"category": r["category"],
|
||||||
|
"count": int(r["cnt"]),
|
||||||
|
"total": int(r["total"]),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def list_for_export(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str | None,
|
||||||
|
date_from: str | None = None,
|
||||||
|
date_to: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""엑셀 내보내기용. email=None 이면 전체 (승인자/관리자용)."""
|
||||||
|
clauses = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if email:
|
||||||
|
clauses.append("owner = %s")
|
||||||
|
params.append(email.lower().strip())
|
||||||
|
if date_from:
|
||||||
|
clauses.append("spent_at >= %s")
|
||||||
|
params.append(date_from)
|
||||||
|
if date_to:
|
||||||
|
clauses.append("spent_at <= %s")
|
||||||
|
params.append(date_to)
|
||||||
|
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM expense_items {where} "
|
||||||
|
f"ORDER BY spent_at ASC, created_at ASC",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
return [self._serialize(r) for r in rows]
|
||||||
|
|
||||||
|
# ── 요약 ──
|
||||||
|
def summary_for(self, email: str) -> dict[str, Any]:
|
||||||
|
email = email.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
head = conn.execute(
|
||||||
|
"SELECT COUNT(*) AS count, COALESCE(SUM(amount), 0) AS total "
|
||||||
|
"FROM expense_items WHERE owner = %s",
|
||||||
|
(email,),
|
||||||
|
).fetchone()
|
||||||
|
status_rows = conn.execute(
|
||||||
|
"SELECT status, COUNT(*) AS c FROM expense_items "
|
||||||
|
"WHERE owner = %s GROUP BY status",
|
||||||
|
(email,),
|
||||||
|
).fetchall()
|
||||||
|
cat_rows = conn.execute(
|
||||||
|
"SELECT category, COALESCE(SUM(amount), 0) AS s "
|
||||||
|
"FROM expense_items WHERE owner = %s GROUP BY category",
|
||||||
|
(email,),
|
||||||
|
).fetchall()
|
||||||
|
by_status = {s: 0 for s in STATUSES}
|
||||||
|
for r in status_rows:
|
||||||
|
by_status[r["status"]] = int(r["c"])
|
||||||
|
by_category = {c: 0 for c in CATEGORIES}
|
||||||
|
for r in cat_rows:
|
||||||
|
by_category[r["category"]] = int(r["s"])
|
||||||
|
return {
|
||||||
|
"count": int(head["count"]) if head else 0,
|
||||||
|
"total": int(head["total"]) if head else 0,
|
||||||
|
"by_status": by_status,
|
||||||
|
"by_category": by_category,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── helpers ──
|
||||||
|
@staticmethod
|
||||||
|
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
out = dict(row)
|
||||||
|
if isinstance(out.get("spent_at"), date):
|
||||||
|
out["spent_at"] = out["spent_at"].isoformat()
|
||||||
|
for k in ("created_at", "updated_at", "decided_at"):
|
||||||
|
v = out.get(k)
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||||
|
out["amount"] = int(out.get("amount", 0))
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize(
|
||||||
|
payload: dict[str, Any], base: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
b = dict(base or {})
|
||||||
|
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
|
||||||
|
category = str(payload.get("category") or b.get("category") or "기타").strip()
|
||||||
|
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
||||||
|
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
|
||||||
|
b["category"] = category or "기타"
|
||||||
|
b["method"] = method if method in METHODS else "법인카드"
|
||||||
|
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
||||||
|
try:
|
||||||
|
b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
b["amount"] = 0
|
||||||
|
b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip()
|
||||||
|
return b
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""개인경비 항목 JSON 저장소.
|
||||||
|
|
||||||
|
- 저장 위치: DATA_DIR/expense.json
|
||||||
|
- 사용자(email) 단위 소유. 본인 항목만 조회/수정/삭제.
|
||||||
|
- 향후 expense_db(PostgreSQL)로 마이그레이션 예정. 현재는 DB 생성 승인 전이라 JSON 사용.
|
||||||
|
- 동시성: 프로세스 내 threading.Lock + 원자적 쓰기(temp → rename). UserStore 와 동일 패턴.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.timezone import now_kst_iso
|
||||||
|
|
||||||
|
CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타")
|
||||||
|
METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금")
|
||||||
|
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료")
|
||||||
|
# 승인 완료(결재 승인 이후) 상태 — "승인완료" 집계/내보내기 대상.
|
||||||
|
APPROVED_STATUSES: tuple[str, ...] = ("승인", "정산완료")
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return now_kst_iso()
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseStore:
|
||||||
|
def __init__(self, path: Path):
|
||||||
|
self._path = path
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not self._path.exists():
|
||||||
|
self._write_atomic({"items": []})
|
||||||
|
|
||||||
|
def _read(self) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with self._path.open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
data = {"items": []}
|
||||||
|
if not isinstance(data.get("items"), list):
|
||||||
|
data["items"] = []
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _write_atomic(self, data: dict[str, Any]) -> None:
|
||||||
|
fd, tmp = tempfile.mkstemp(
|
||||||
|
prefix=".expense.", suffix=".json.tmp", dir=str(self._path.parent)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp, self._path)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
def list_for(self, email: str) -> list[dict[str, Any]]:
|
||||||
|
email = email.lower().strip()
|
||||||
|
with self._lock:
|
||||||
|
data = self._read()
|
||||||
|
return [it for it in data["items"] if it.get("owner") == email]
|
||||||
|
|
||||||
|
def list_all(self) -> list[dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._read()["items"])
|
||||||
|
|
||||||
|
def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None:
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
with self._lock:
|
||||||
|
data = self._read()
|
||||||
|
for it in data["items"]:
|
||||||
|
if it["id"] == item_id and it.get("owner") == owner:
|
||||||
|
return dict(it)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
item = self._normalize(payload)
|
||||||
|
item["id"] = uuid.uuid4().hex[:12]
|
||||||
|
item["owner"] = owner
|
||||||
|
item["status"] = payload.get("status") or "작성중"
|
||||||
|
item["created_at"] = _now_iso()
|
||||||
|
item["updated_at"] = item["created_at"]
|
||||||
|
with self._lock:
|
||||||
|
data = self._read()
|
||||||
|
data["items"].append(item)
|
||||||
|
self._write_atomic(data)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self, *, item_id: str, owner: str, payload: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
with self._lock:
|
||||||
|
data = self._read()
|
||||||
|
for idx, it in enumerate(data["items"]):
|
||||||
|
if it["id"] == item_id and it.get("owner") == owner:
|
||||||
|
new = self._normalize(payload, base=it)
|
||||||
|
new["id"] = it["id"]
|
||||||
|
new["owner"] = it["owner"]
|
||||||
|
new["created_at"] = it.get("created_at", _now_iso())
|
||||||
|
new["status"] = payload.get("status") or it.get("status", "작성중")
|
||||||
|
new["updated_at"] = _now_iso()
|
||||||
|
data["items"][idx] = new
|
||||||
|
self._write_atomic(data)
|
||||||
|
return new
|
||||||
|
raise KeyError(item_id)
|
||||||
|
|
||||||
|
def delete(self, *, item_id: str, owner: str) -> None:
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
with self._lock:
|
||||||
|
data = self._read()
|
||||||
|
before = len(data["items"])
|
||||||
|
data["items"] = [
|
||||||
|
it
|
||||||
|
for it in data["items"]
|
||||||
|
if not (it["id"] == item_id and it.get("owner") == owner)
|
||||||
|
]
|
||||||
|
if len(data["items"]) == before:
|
||||||
|
raise KeyError(item_id)
|
||||||
|
self._write_atomic(data)
|
||||||
|
|
||||||
|
def summary_for(self, email: str) -> dict[str, Any]:
|
||||||
|
items = self.list_for(email)
|
||||||
|
total = sum(int(i.get("amount", 0)) for i in items)
|
||||||
|
by_status = {s: sum(1 for i in items if i.get("status") == s) for s in STATUSES}
|
||||||
|
by_category = {
|
||||||
|
c: sum(int(i.get("amount", 0)) for i in items if i.get("category") == c)
|
||||||
|
for c in CATEGORIES
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"count": len(items),
|
||||||
|
"total": total,
|
||||||
|
"by_status": by_status,
|
||||||
|
"by_category": by_category,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize(
|
||||||
|
payload: dict[str, Any], base: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
b = dict(base or {})
|
||||||
|
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
|
||||||
|
category = str(payload.get("category") or b.get("category") or "기타").strip()
|
||||||
|
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
||||||
|
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
|
||||||
|
b["category"] = category or "기타"
|
||||||
|
b["method"] = method if method in METHODS else "법인카드"
|
||||||
|
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
||||||
|
try:
|
||||||
|
b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
b["amount"] = 0
|
||||||
|
b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip()
|
||||||
|
return b
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="erp-ex-approved">
|
||||||
|
|
||||||
|
<!-- ── 상단 액션 + 월 선택 ── -->
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-ghost" href="/expense/">← 개인경비</a>
|
||||||
|
<form id="mon-form" method="get" action="/expense/approved" style="display:flex; gap:var(--sp-8); align-items:center; margin:0;">
|
||||||
|
<input type="month" name="month" value="{{ month }}" />
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
|
||||||
|
</form>
|
||||||
|
<a class="erp-btn erp-btn-primary" href="/expense/api/approved/attachments.zip?month={{ month }}">전부 다운로드(zip)</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 직원별 합계 ── -->
|
||||||
|
<div class="erp-card-block">
|
||||||
|
<div class="erp-card-block-head">
|
||||||
|
<h2>직원별 승인 금액 합계</h2>
|
||||||
|
<span class="erp-muted">{{ month }} · 총 {{ "{:,}".format(grand_total) }} 원</span>
|
||||||
|
</div>
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>이메일</th>
|
||||||
|
<th style="width:100px; text-align:right;">건수</th>
|
||||||
|
<th style="width:160px; text-align:right;">합계금액</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in owner_summary %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td class="erp-muted">{{ r.owner }}</td>
|
||||||
|
<td style="text-align:right;">{{ r.count }}</td>
|
||||||
|
<td style="text-align:right; font-variant-numeric: tabular-nums;">{{ "{:,}".format(r.total) }} 원</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4" class="erp-empty">해당 월 승인완료 항목이 없습니다.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% if owner_summary %}
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<th colspan="2" style="text-align:right;">합계</th>
|
||||||
|
<th style="text-align:right;">{{ count }}</th>
|
||||||
|
<th style="text-align:right;">{{ "{:,}".format(grand_total) }} 원</th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 승인완료 항목 목록 ── -->
|
||||||
|
<div class="erp-card-block">
|
||||||
|
<div class="erp-card-block-head">
|
||||||
|
<h2>승인완료 항목 ({{ count }}건)</h2>
|
||||||
|
<span class="erp-muted">전 직원 · 사용일 기준 {{ month }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table" id="appr-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:110px;">사용일</th>
|
||||||
|
<th style="width:120px;">이름</th>
|
||||||
|
<th style="width:90px;">분류</th>
|
||||||
|
<th style="width:100px;">수단</th>
|
||||||
|
<th>가맹점/메모</th>
|
||||||
|
<th style="width:120px; text-align:right;">금액</th>
|
||||||
|
<th style="width:90px;">상태</th>
|
||||||
|
<th style="width:60px; text-align:center;">첨부</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in items %}
|
||||||
|
<tr data-id="{{ it.id }}">
|
||||||
|
<td>{{ it.spent_at }}</td>
|
||||||
|
<td>{{ it.owner_name }}<div class="erp-row-sub">{{ it.owner }}</div></td>
|
||||||
|
<td>{{ it.category }}</td>
|
||||||
|
<td>{{ it.method }}</td>
|
||||||
|
<td>
|
||||||
|
<div>{{ it.merchant }}</div>
|
||||||
|
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right; font-variant-numeric: tabular-nums;">{{ "{:,}".format(it.amount) }} 원</td>
|
||||||
|
<td>
|
||||||
|
{% if it.status == '정산완료' %}
|
||||||
|
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="erp-badge erp-badge-inverse">{{ it.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center;">
|
||||||
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-view-att" title="첨부 보기">📎</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8" class="erp-empty">해당 월 승인완료 항목이 없습니다.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.erp-page-actions { display:flex; gap:var(--sp-8); margin-bottom:var(--sp-16); flex-wrap:wrap; align-items:center; }
|
||||||
|
.erp-ex-approved .erp-row-sub { font-size:12px; color:var(--color-text-muted, #888); }
|
||||||
|
#appr-table td { vertical-align: middle; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// 월 선택 변경 시 자동 조회
|
||||||
|
const monInput = document.querySelector('#mon-form input[name="month"]');
|
||||||
|
if (monInput) monInput.addEventListener("change", () => monInput.form.submit());
|
||||||
|
|
||||||
|
// 첨부 보기 (공용 뷰어)
|
||||||
|
const tbody = document.querySelector("#appr-table tbody");
|
||||||
|
if (tbody) {
|
||||||
|
tbody.addEventListener("click", (e) => {
|
||||||
|
const btn = e.target.closest("button.js-view-att");
|
||||||
|
if (!btn) return;
|
||||||
|
const tr = btn.closest("tr[data-id]");
|
||||||
|
const id = tr.dataset.id;
|
||||||
|
const who = tr.children[1].textContent.trim();
|
||||||
|
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${who}` });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="erp-expense">
|
||||||
|
|
||||||
|
<!-- ── 페이지 액션 ── -->
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
{% if is_approver %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/pending">
|
||||||
|
승인 대기 {% if pending_count %}<strong style="margin-left:6px;">{{ pending_count }}</strong>{% endif %}
|
||||||
|
</a>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/approved">승인완료</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=mine">엑셀(내 항목)</a>
|
||||||
|
{% if is_approver %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_approver %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/settings">⚙ 설정</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 요약 카드 ── -->
|
||||||
|
<div class="erp-summary-grid">
|
||||||
|
{% for status in statuses %}
|
||||||
|
<div class="erp-summary-card erp-summary-card--mini">
|
||||||
|
<span class="erp-summary-label">{{ status }}</span>
|
||||||
|
<strong class="erp-summary-value erp-summary-value--sm">
|
||||||
|
{{ summary.by_status.get(status, 0) }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 등록(좌) / 내역(우) 2단 ── -->
|
||||||
|
<div class="ex-two-col">
|
||||||
|
|
||||||
|
<!-- ── 입력 폼 ── -->
|
||||||
|
<div class="erp-card-block">
|
||||||
|
<div class="erp-card-block-head">
|
||||||
|
<h2>경비 등록</h2>
|
||||||
|
<span class="erp-muted">필수 항목 입력 후 등록. 등록 후 항목별로 첨부/제출.</span>
|
||||||
|
</div>
|
||||||
|
<form id="ex-form" class="erp-form-grid">
|
||||||
|
<input type="hidden" name="id" />
|
||||||
|
<label class="erp-field"><span>사용일</span><input type="date" name="spent_at" required /></label>
|
||||||
|
<label class="erp-field"><span>분류</span>
|
||||||
|
<select name="category" required>
|
||||||
|
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="erp-field"><span>결제수단</span>
|
||||||
|
<select name="method" required>
|
||||||
|
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="erp-field"><span>금액</span>
|
||||||
|
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
|
||||||
|
</label>
|
||||||
|
<label class="erp-field erp-field-wide"><span>가맹점/사용처</span>
|
||||||
|
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" required />
|
||||||
|
</label>
|
||||||
|
<label class="erp-field erp-field-wide"><span>메모</span>
|
||||||
|
<input type="text" name="memo" placeholder="비고" />
|
||||||
|
</label>
|
||||||
|
<div class="erp-form-actions">
|
||||||
|
<button type="reset" class="erp-btn erp-btn-outline" id="ex-reset">초기화</button>
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary" id="ex-submit">등록</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 항목 목록 ── -->
|
||||||
|
<div class="erp-card-block">
|
||||||
|
<div class="erp-card-block-head">
|
||||||
|
<h2>경비 내역</h2>
|
||||||
|
<div style="display: flex; align-items: center; gap: var(--sp-12); flex-wrap: wrap;">
|
||||||
|
<form method="get" action="/expense/" id="ex-month-form" style="display:flex; gap:var(--sp-8); align-items:center; margin:0;">
|
||||||
|
<input type="month" name="month" value="{{ month }}" />
|
||||||
|
</form>
|
||||||
|
<span class="ex-month-stat" id="ex-count">{{ items | length }}건</span>
|
||||||
|
<span class="ex-month-stat">합계 <strong>{{ "{:,}".format(month_total) }}</strong> 원</span>
|
||||||
|
{% if supports_workflow %}
|
||||||
|
<button id="ex-bulk-submit" class="erp-btn erp-btn-primary erp-btn-sm" disabled>
|
||||||
|
선택 항목 제출 (<span id="ex-bulk-count">0</span>)
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table" id="ex-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 36px; text-align: center;">
|
||||||
|
<input type="checkbox" id="ex-select-all" title="전체 선택" />
|
||||||
|
</th>
|
||||||
|
<th style="width: 110px;">사용일</th>
|
||||||
|
<th style="width: 90px;">분류</th>
|
||||||
|
<th style="width: 100px;">수단</th>
|
||||||
|
<th>가맹점</th>
|
||||||
|
<th style="width: 120px;">금액</th>
|
||||||
|
<th style="width: 90px;">상태</th>
|
||||||
|
<th style="width: 240px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ex-tbody">
|
||||||
|
{% for it in items %}
|
||||||
|
<tr data-id="{{ it.id }}" data-status="{{ it.status }}">
|
||||||
|
<td style="text-align: center;">
|
||||||
|
{% if it.status in ('작성중', '반려') and supports_workflow %}
|
||||||
|
<input type="checkbox" class="js-select" />
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ it.spent_at }}</td>
|
||||||
|
<td>{{ it.category }}</td>
|
||||||
|
<td>{{ it.method }}</td>
|
||||||
|
<td>
|
||||||
|
<div>{{ it.merchant }}</div>
|
||||||
|
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
||||||
|
{% if it.reject_reason %}
|
||||||
|
<div class="erp-row-sub" style="color: var(--color-callout-red);">반려: {{ it.reject_reason }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||||
|
{{ "{:,}".format(it.amount) }} 원
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if it.status == '작성중' or it.status == '반려' %}
|
||||||
|
<span class="erp-badge erp-badge-outline">{{ it.status }}</span>
|
||||||
|
{% elif it.status == '제출' %}
|
||||||
|
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||||
|
{% elif it.status == '승인' %}
|
||||||
|
<span class="erp-badge erp-badge-inverse">{{ it.status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right;" class="js-actions">
|
||||||
|
{% if it.status in ('작성중', '반려') and supports_workflow %}
|
||||||
|
<label class="erp-btn erp-btn-outline erp-btn-sm">
|
||||||
|
영수증<input type="file" class="js-upload" data-kind="receipt" hidden />
|
||||||
|
</label>
|
||||||
|
<label class="erp-btn erp-btn-outline erp-btn-sm">
|
||||||
|
기타 파일<input type="file" class="js-upload" data-kind="other" hidden />
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
{% if it.status in ('작성중', '반려') %}
|
||||||
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
|
||||||
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
|
||||||
|
{% elif it.status == '제출' and supports_workflow %}
|
||||||
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-revert">취소(작성중)</button>
|
||||||
|
{% endif %}
|
||||||
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-view-att" title="첨부 보기">📎</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr id="ex-empty"><td colspan="8" class="erp-empty">등록된 경비가 없습니다.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /ex-two-col -->
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.erp-page-actions {
|
||||||
|
display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
#ex-tbody td { vertical-align: middle; }
|
||||||
|
|
||||||
|
/* 경비 내역 월 통계(건수/합계) 강조 */
|
||||||
|
.ex-month-stat { font-size: 18px; font-weight: 600; color: var(--color-text, #1a1a1a); }
|
||||||
|
.ex-month-stat strong { font-size: 20px; }
|
||||||
|
#ex-tbody .js-actions { white-space: nowrap; }
|
||||||
|
#ex-tbody .js-actions > * { margin-left: 4px; vertical-align: middle; }
|
||||||
|
|
||||||
|
/* 상단 요약 카드: 세로 70px 고정 (grid 행 높이 고정 → stretch 무력화) */
|
||||||
|
.erp-expense .erp-summary-grid {
|
||||||
|
grid-auto-rows: 70px !important;
|
||||||
|
margin-bottom: var(--sp-20); /* 등록 폼과 간격 */
|
||||||
|
}
|
||||||
|
.erp-expense .erp-summary-card {
|
||||||
|
height: 70px !important; min-height: 0 !important; max-height: 70px;
|
||||||
|
padding: 8px 14px; justify-content: center; gap: 2px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.erp-expense .erp-summary-card--mini { padding: 8px 14px; }
|
||||||
|
/* 요약줄과 2단 블록 사이 간격 */
|
||||||
|
.ex-two-col { margin-top: var(--sp-20); }
|
||||||
|
|
||||||
|
/* 경비 내역 테이블: 전부 가운데 정렬 + 한 줄(2줄 방지) + 너비 자동 */
|
||||||
|
#ex-table { table-layout: auto; }
|
||||||
|
#ex-table th, #ex-table td {
|
||||||
|
text-align: center !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: auto !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
#ex-table th:first-child, #ex-table td:first-child { width: 36px !important; }
|
||||||
|
#ex-table td > div { white-space: nowrap; } /* 가맹점/메모 줄바꿈 방지 */
|
||||||
|
#ex-tbody .js-actions { text-align: center !important; }
|
||||||
|
#ex-tbody .js-actions > * { margin: 0 2px; }
|
||||||
|
|
||||||
|
/* 첫 행(헤더) 모서리 라운드 제거 — 라운드는 .erp-table-wrap 에 걸려 있음 */
|
||||||
|
.erp-expense .erp-table-wrap { border-radius: 0 !important; box-shadow: none; }
|
||||||
|
|
||||||
|
/* 첨부(클립) 아이콘 크게 */
|
||||||
|
#ex-tbody .js-view-att { font-size: 22px !important; line-height: 1; padding: 2px 8px; }
|
||||||
|
.erp-expense .erp-summary-value { font-size: 20px; line-height: 1.1; }
|
||||||
|
.erp-expense .erp-summary-value--sm { font-size: 18px; }
|
||||||
|
|
||||||
|
/* 경비 등록(좌) / 경비 내역(우) 2단 */
|
||||||
|
.ex-two-col {
|
||||||
|
display: grid; grid-template-columns: 520px minmax(0, 1fr);
|
||||||
|
gap: var(--sp-16); align-items: start;
|
||||||
|
}
|
||||||
|
.ex-two-col > .erp-card-block { margin: 0; }
|
||||||
|
/* 좌측 폼은 2열로 (가맹점/메모는 전체폭) */
|
||||||
|
.ex-two-col .erp-form-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.ex-two-col .erp-form-grid .erp-field-wide,
|
||||||
|
.ex-two-col .erp-form-grid .erp-form-actions { grid-column: 1 / -1; }
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.ex-two-col { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const supportsWorkflow = {{ 'true' if supports_workflow else 'false' }};
|
||||||
|
const form = document.getElementById("ex-form");
|
||||||
|
const submitBtn = document.getElementById("ex-submit");
|
||||||
|
const resetBtn = document.getElementById("ex-reset");
|
||||||
|
const tbody = document.getElementById("ex-tbody");
|
||||||
|
const selectAll = document.getElementById("ex-select-all");
|
||||||
|
const bulkBtn = document.getElementById("ex-bulk-submit");
|
||||||
|
const bulkCountEl = document.getElementById("ex-bulk-count");
|
||||||
|
|
||||||
|
function setEditing(id, data) {
|
||||||
|
form.id.value = id || "";
|
||||||
|
if (data) {
|
||||||
|
form.spent_at.value = data.spent_at || "";
|
||||||
|
form.category.value = data.category || "";
|
||||||
|
form.method.value = data.method || "";
|
||||||
|
form.amount.value = data.amount || 0;
|
||||||
|
form.merchant.value = data.merchant || "";
|
||||||
|
form.memo.value = data.memo || "";
|
||||||
|
submitBtn.textContent = "수정 저장";
|
||||||
|
} else {
|
||||||
|
submitBtn.textContent = "등록";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resetBtn.addEventListener("click", () => setEditing("", null));
|
||||||
|
|
||||||
|
// 필수 입력 검증 — 누락 시 경고창
|
||||||
|
function validateRequired() {
|
||||||
|
const required = [
|
||||||
|
["spent_at", "사용일"],
|
||||||
|
["category", "분류"],
|
||||||
|
["method", "결제수단"],
|
||||||
|
["amount", "금액"],
|
||||||
|
["merchant", "가맹점/사용처"],
|
||||||
|
];
|
||||||
|
const missing = [];
|
||||||
|
for (const [field, label] of required) {
|
||||||
|
const val = (form[field].value || "").trim();
|
||||||
|
if (!val) missing.push([form[field], label]);
|
||||||
|
}
|
||||||
|
// 금액은 0 이하도 미입력 취급
|
||||||
|
if (!missing.some(([f]) => f === form.amount)) {
|
||||||
|
if (!(parseInt(form.amount.value, 10) > 0)) {
|
||||||
|
missing.push([form.amount, "금액(1원 이상)"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (missing.length) {
|
||||||
|
alert("다음 항목을 입력하세요:\n- " + missing.map(([, l]) => l).join("\n- "));
|
||||||
|
missing[0][0].focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validateRequired()) return;
|
||||||
|
const id = form.id.value.trim();
|
||||||
|
const payload = {
|
||||||
|
spent_at: form.spent_at.value,
|
||||||
|
category: form.category.value,
|
||||||
|
method: form.method.value,
|
||||||
|
amount: parseInt(form.amount.value || "0", 10),
|
||||||
|
merchant: form.merchant.value,
|
||||||
|
memo: form.memo.value,
|
||||||
|
};
|
||||||
|
const url = id ? `/expense/api/items/${id}` : "/expense/api/items";
|
||||||
|
const method = id ? "PUT" : "POST";
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||||
|
location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`저장 실패: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 체크박스 상태 갱신
|
||||||
|
function refreshSelection() {
|
||||||
|
if (!bulkBtn) return;
|
||||||
|
const cbs = tbody.querySelectorAll("input.js-select");
|
||||||
|
const checked = Array.from(cbs).filter((cb) => cb.checked);
|
||||||
|
bulkBtn.disabled = checked.length === 0;
|
||||||
|
if (bulkCountEl) bulkCountEl.textContent = checked.length;
|
||||||
|
if (selectAll && cbs.length > 0) {
|
||||||
|
selectAll.checked = checked.length === cbs.length;
|
||||||
|
selectAll.indeterminate = checked.length > 0 && checked.length < cbs.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.addEventListener("change", () => {
|
||||||
|
tbody.querySelectorAll("input.js-select").forEach((cb) => {
|
||||||
|
cb.checked = selectAll.checked;
|
||||||
|
});
|
||||||
|
refreshSelection();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.addEventListener("change", (e) => {
|
||||||
|
if (e.target.classList.contains("js-select")) refreshSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bulkBtn) {
|
||||||
|
bulkBtn.addEventListener("click", async () => {
|
||||||
|
const ids = Array.from(tbody.querySelectorAll("input.js-select:checked"))
|
||||||
|
.map((cb) => cb.closest("tr").dataset.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
if (!confirm(`${ids.length}건을 결재 제출할까요? 제출 후에는 수정 불가.`)) return;
|
||||||
|
bulkBtn.disabled = true;
|
||||||
|
bulkBtn.textContent = "제출 중…";
|
||||||
|
const fails = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/expense/api/items/${id}/submit`, { method: "POST" });
|
||||||
|
if (!res.ok) fails.push(`${id}: ${(await res.json()).detail || res.status}`);
|
||||||
|
} catch (err) { fails.push(`${id}: ${err.message || err}`); }
|
||||||
|
}
|
||||||
|
if (fails.length) alert("일부 실패:\n" + fails.join("\n"));
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 행 동작 (수정/삭제/취소/첨부보기 + 업로드)
|
||||||
|
tbody.addEventListener("click", async (e) => {
|
||||||
|
const btn = e.target.closest("button");
|
||||||
|
if (!btn) return;
|
||||||
|
const tr = btn.closest("tr[data-id]");
|
||||||
|
if (!tr) return;
|
||||||
|
const id = tr.dataset.id;
|
||||||
|
|
||||||
|
if (btn.classList.contains("js-edit")) {
|
||||||
|
const res = await fetch(`/expense/api/items`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const { items } = await res.json();
|
||||||
|
const found = items.find((x) => x.id === id);
|
||||||
|
if (found) setEditing(id, found);
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
} else if (btn.classList.contains("js-delete")) {
|
||||||
|
if (!confirm("이 항목을 삭제할까요? 첨부도 함께 삭제됩니다.")) return;
|
||||||
|
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) location.reload(); else alert((await res.json()).detail || "삭제 실패");
|
||||||
|
} else if (btn.classList.contains("js-revert")) {
|
||||||
|
if (!confirm("작성중 상태로 되돌릴까요?")) return;
|
||||||
|
const res = await fetch(`/expense/api/items/${id}/revert`, { method: "POST" });
|
||||||
|
if (res.ok) location.reload(); else alert((await res.json()).detail || "취소 실패");
|
||||||
|
} else if (btn.classList.contains("js-view-att")) {
|
||||||
|
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${tr.children[1].textContent.trim()}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 첨부 업로드
|
||||||
|
tbody.addEventListener("change", async (e) => {
|
||||||
|
const input = e.target.closest("input.js-upload");
|
||||||
|
if (!input || !input.files.length) return;
|
||||||
|
const tr = input.closest("tr[data-id]");
|
||||||
|
const itemId = tr.dataset.id;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", input.files[0]);
|
||||||
|
fd.append("kind", input.dataset.kind || "other");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/expense/api/items/${itemId}/attachments`, {
|
||||||
|
method: "POST", body: fd,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||||
|
input.value = "";
|
||||||
|
alert("첨부 업로드 완료");
|
||||||
|
} catch (err) {
|
||||||
|
alert(`업로드 실패: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!form.spent_at.value) {
|
||||||
|
const d = new Date();
|
||||||
|
form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 월 선택 변경 시 자동 조회
|
||||||
|
const monthInput = document.querySelector('#ex-month-form input[name="month"]');
|
||||||
|
if (monthInput) monthInput.addEventListener("change", () => monthInput.form.submit());
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="erp-pending">
|
||||||
|
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-ghost" href="/expense/">← 내 개인경비</a>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-card-block">
|
||||||
|
<div class="erp-card-block-head">
|
||||||
|
<h2>승인 대기 ({{ items | length }}건)</h2>
|
||||||
|
<div style="display: flex; align-items: center; gap: var(--sp-12);">
|
||||||
|
<span class="erp-muted">제출 상태 항목 — 일괄 승인 / 항목별 반려</span>
|
||||||
|
<button id="pend-bulk-approve" class="erp-btn erp-btn-primary erp-btn-sm" disabled>
|
||||||
|
선택 일괄 승인 (<span id="pend-bulk-count">0</span>)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 36px; text-align: center;">
|
||||||
|
<input type="checkbox" id="pend-select-all" title="전체 선택" />
|
||||||
|
</th>
|
||||||
|
<th style="width: 110px;">사용일</th>
|
||||||
|
<th style="width: 200px;">소유자</th>
|
||||||
|
<th style="width: 90px;">분류</th>
|
||||||
|
<th>가맹점/메모</th>
|
||||||
|
<th style="width: 120px; text-align: right;">금액</th>
|
||||||
|
<th style="width: 200px; text-align: right;">동작</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="pend-tbody">
|
||||||
|
{% for it in items %}
|
||||||
|
<tr data-id="{{ it.id }}">
|
||||||
|
<td style="text-align: center;">
|
||||||
|
<input type="checkbox" class="js-select" />
|
||||||
|
</td>
|
||||||
|
<td>{{ it.spent_at }}</td>
|
||||||
|
<td>{{ it.owner }}</td>
|
||||||
|
<td>{{ it.category }}</td>
|
||||||
|
<td>
|
||||||
|
<div>{{ it.merchant }}</div>
|
||||||
|
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||||
|
{{ "{:,}".format(it.amount) }} 원
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right;">
|
||||||
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-attach" title="첨부 보기" aria-label="첨부 보기">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66L9.41 17.41a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="erp-btn erp-btn-outline erp-btn-sm js-reject">반려</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="erp-empty">대기 항목이 없습니다.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
|
||||||
|
#pend-tbody td { vertical-align: middle; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const tbody = document.getElementById("pend-tbody");
|
||||||
|
const selectAll = document.getElementById("pend-select-all");
|
||||||
|
const bulkBtn = document.getElementById("pend-bulk-approve");
|
||||||
|
const bulkCountEl = document.getElementById("pend-bulk-count");
|
||||||
|
|
||||||
|
function refreshSelection() {
|
||||||
|
const cbs = tbody.querySelectorAll("input.js-select");
|
||||||
|
const checked = Array.from(cbs).filter((cb) => cb.checked);
|
||||||
|
bulkBtn.disabled = checked.length === 0;
|
||||||
|
bulkCountEl.textContent = checked.length;
|
||||||
|
if (cbs.length > 0) {
|
||||||
|
selectAll.checked = checked.length === cbs.length;
|
||||||
|
selectAll.indeterminate = checked.length > 0 && checked.length < cbs.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAll.addEventListener("change", () => {
|
||||||
|
tbody.querySelectorAll("input.js-select").forEach((cb) => {
|
||||||
|
cb.checked = selectAll.checked;
|
||||||
|
});
|
||||||
|
refreshSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.addEventListener("change", (e) => {
|
||||||
|
if (e.target.classList.contains("js-select")) refreshSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
bulkBtn.addEventListener("click", async () => {
|
||||||
|
const ids = Array.from(tbody.querySelectorAll("input.js-select:checked"))
|
||||||
|
.map((cb) => cb.closest("tr").dataset.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
if (!confirm(`${ids.length}건을 일괄 승인할까요?`)) return;
|
||||||
|
bulkBtn.disabled = true;
|
||||||
|
bulkBtn.textContent = "승인 중…";
|
||||||
|
const fails = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/expense/api/items/${id}/approve`, { method: "POST" });
|
||||||
|
if (!res.ok) fails.push(`${id}: ${(await res.json()).detail || res.status}`);
|
||||||
|
} catch (err) { fails.push(`${id}: ${err.message || err}`); }
|
||||||
|
}
|
||||||
|
if (fails.length) alert("일부 실패:\n" + fails.join("\n"));
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.addEventListener("click", async (e) => {
|
||||||
|
const btn = e.target.closest("button");
|
||||||
|
if (!btn) return;
|
||||||
|
const tr = btn.closest("tr[data-id]");
|
||||||
|
const id = tr.dataset.id;
|
||||||
|
|
||||||
|
if (btn.classList.contains("js-attach")) {
|
||||||
|
const owner = tr.children[2]?.textContent?.trim() || "";
|
||||||
|
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${owner}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (btn.classList.contains("js-reject")) {
|
||||||
|
const reason = prompt("반려 사유를 입력하세요:");
|
||||||
|
if (!reason || !reason.trim()) return;
|
||||||
|
const res = await fetch(`/expense/api/items/${id}/reject`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ reason: reason.trim() }),
|
||||||
|
});
|
||||||
|
if (res.ok) { tr.remove(); refreshSelection(); }
|
||||||
|
else { alert((await res.json()).detail || "반려 실패"); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="erp-reports">
|
||||||
|
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-ghost" href="/expense/">← 내 개인경비</a>
|
||||||
|
<form method="get" style="display: inline-flex; gap: 6px; align-items: center;">
|
||||||
|
<label class="erp-muted">연도</label>
|
||||||
|
<input type="number" name="year" value="{{ year }}" min="2000" max="2100"
|
||||||
|
style="width: 100px; padding: 6px 10px; border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: var(--r-input); font-family: inherit;" />
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
|
||||||
|
</form>
|
||||||
|
<a class="erp-btn erp-btn-outline"
|
||||||
|
href="/expense/api/export.xlsx?from={{ year }}-01-01&to={{ year }}-12-31">엑셀</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-card-block">
|
||||||
|
<div class="erp-card-block-head">
|
||||||
|
<h2>{{ year }}년 월별 / 카테고리별 합계</h2>
|
||||||
|
<span class="erp-muted">총 {{ "{:,}".format(grand_total) }} 원</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 110px;">월</th>
|
||||||
|
{% for c in categories %}
|
||||||
|
<th style="text-align: right;">{{ c }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th style="text-align: right; background: var(--color-ghost-gray);">합계</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in months %}
|
||||||
|
{% set row_total = pivot[m].values() | sum %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ m }}</strong></td>
|
||||||
|
{% for c in categories %}
|
||||||
|
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||||
|
{% if pivot[m].get(c) %}{{ "{:,}".format(pivot[m][c]) }}{% else %}-{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td style="text-align: right; font-variant-numeric: tabular-nums; background: var(--color-ghost-gray);">
|
||||||
|
<strong>{{ "{:,}".format(row_total) }}</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="{{ categories | length + 2 }}" class="erp-empty">데이터 없음</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% if months %}
|
||||||
|
<tfoot>
|
||||||
|
<tr style="background: var(--color-ghost-gray);">
|
||||||
|
<th>합계</th>
|
||||||
|
{% for c in categories %}
|
||||||
|
<th style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||||
|
{{ "{:,}".format(cat_totals[c]) }}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||||
|
{{ "{:,}".format(grand_total) }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); align-items: center; flex-wrap: wrap; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="erp-ex-settings">
|
||||||
|
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-ghost" href="/expense/">← 개인경비</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-card-block" style="max-width: 640px;">
|
||||||
|
<div class="erp-card-block-head">
|
||||||
|
<h2>분류 항목 관리</h2>
|
||||||
|
<span class="erp-muted">추가/삭제 즉시 사용자 등록 폼에 반영됩니다.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="cat-form" class="erp-form-grid" style="grid-template-columns: 1fr auto; align-items: end; gap: var(--sp-12);">
|
||||||
|
<label class="erp-field"><span>새 분류명</span>
|
||||||
|
<input type="text" name="name" maxlength="30" placeholder="예) 마케팅" required />
|
||||||
|
</label>
|
||||||
|
<div class="erp-form-actions" style="margin: 0;">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary">추가</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="erp-table-wrap" style="margin-top: var(--sp-16);">
|
||||||
|
<table class="erp-table" id="cat-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>분류명</th>
|
||||||
|
<th style="width: 100px; text-align: right;">동작</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="cat-tbody">
|
||||||
|
{% for c in categories %}
|
||||||
|
<tr data-name="{{ c }}">
|
||||||
|
<td>{{ c }}</td>
|
||||||
|
<td style="text-align: right;">
|
||||||
|
<button class="erp-btn erp-btn-outline erp-btn-sm js-del">삭제</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="erp-muted" style="margin-top: var(--sp-12);">
|
||||||
|
삭제해도 이미 등록된 경비 항목의 분류는 그대로 유지됩니다. 분류는 최소 1개 이상이어야 합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
|
||||||
|
#cat-tbody td { vertical-align: middle; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const form = document.getElementById("cat-form");
|
||||||
|
const tbody = document.getElementById("cat-tbody");
|
||||||
|
|
||||||
|
function rowFor(name) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.dataset.name = name;
|
||||||
|
tr.innerHTML =
|
||||||
|
`<td></td>` +
|
||||||
|
`<td style="text-align: right;">` +
|
||||||
|
`<button class="erp-btn erp-btn-outline erp-btn-sm js-del">삭제</button></td>`;
|
||||||
|
tr.children[0].textContent = name;
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(categories) {
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
categories.forEach((c) => tbody.appendChild(rowFor(c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = form.name.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/expense/api/categories", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.detail || res.status);
|
||||||
|
render(data.categories);
|
||||||
|
form.reset();
|
||||||
|
form.name.focus();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`추가 실패: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.addEventListener("click", async (e) => {
|
||||||
|
const btn = e.target.closest("button.js-del");
|
||||||
|
if (!btn) return;
|
||||||
|
const tr = btn.closest("tr[data-name]");
|
||||||
|
const name = tr.dataset.name;
|
||||||
|
if (!confirm(`분류 "${name}" 을(를) 삭제할까요?`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/expense/api/categories/${encodeURIComponent(name)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.detail || res.status);
|
||||||
|
render(data.categories);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`삭제 실패: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -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 불필요.
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"}
|
||||||
@@ -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 %}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""휴가 관리(vacation) 모듈.
|
||||||
|
|
||||||
|
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
|
||||||
|
- 라우터: `router.py` (FastAPI APIRouter, prefix=/vacation)
|
||||||
|
- 저장소: `db.py` (vacation_db / PostgreSQL 전용) + `store.py` (상수/일수 계산)
|
||||||
|
- 템플릿: `templates/vacation/`
|
||||||
|
|
||||||
|
데이터 저장은 vacation_db 전용이다. VACATION_DB_URL 미설정 시 build_vacation_store 는
|
||||||
|
None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를 보여준다(앱은 죽지 않음).
|
||||||
|
|
||||||
|
권한:
|
||||||
|
- `vacation` : 휴가 관리 접근
|
||||||
|
- `vacation_approver` : 승인/반려 (admin 은 항상 통과)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .router import router
|
||||||
|
from .store import HALF_LABELS, HALVES, STATUSES, VACATION_TYPES, compute_days
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"router",
|
||||||
|
"VACATION_TYPES",
|
||||||
|
"STATUSES",
|
||||||
|
"HALVES",
|
||||||
|
"HALF_LABELS",
|
||||||
|
"compute_days",
|
||||||
|
"build_vacation_store",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_vacation_store(*, dsn: str | None) -> Any:
|
||||||
|
"""VACATION_DB_URL 이 있으면 VacationDBStore, 없으면 None.
|
||||||
|
|
||||||
|
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시.
|
||||||
|
"""
|
||||||
|
if not dsn:
|
||||||
|
return None
|
||||||
|
from .db import VacationDBStore # 지연 import (개발 환경 deps 없을 수 있음)
|
||||||
|
|
||||||
|
return VacationDBStore(dsn)
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
"""vacation_db PostgreSQL 저장소.
|
||||||
|
|
||||||
|
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — expense/cupang 모듈과 동일 패턴.
|
||||||
|
- 연결 정보: 환경변수 `VACATION_DB_URL`
|
||||||
|
(예: postgresql://vacation_app:<pwd>@postgres-db:5432/vacation_db)
|
||||||
|
- 스키마(테이블/인덱스/트리거/seed)는 앱이 만들지 않는다.
|
||||||
|
`scripts/sql/vacation_db_init.sql` 을 superuser 가 사전 적용한다.
|
||||||
|
앱 계정(vacation_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다.
|
||||||
|
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
|
||||||
|
|
||||||
|
휴가 일수는 서버에서 `store.compute_days` 로 재계산하여 저장한다.
|
||||||
|
공휴일 집합은 vacation_holidays(is_red=TRUE)에서 읽어 계산에 넘긴다(holiday provider).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
|
from app.timezone import KST
|
||||||
|
|
||||||
|
from .store import EDITABLE_STATUSES, STATUSES, VACATION_TYPES, compute_days, normalize_half
|
||||||
|
|
||||||
|
|
||||||
|
class VacationDBStore:
|
||||||
|
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||||
|
self._pool = ConnectionPool(
|
||||||
|
conninfo=dsn,
|
||||||
|
min_size=min_size,
|
||||||
|
max_size=max_size,
|
||||||
|
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||||
|
open=False,
|
||||||
|
)
|
||||||
|
self._pool.open(wait=False)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._pool.close()
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 공휴일 (vacation_holidays) — holiday provider
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def list_holidays(self, *, year: int | None = None) -> list[dict[str, Any]]:
|
||||||
|
clause = ""
|
||||||
|
params: list[Any] = []
|
||||||
|
if year:
|
||||||
|
clause = "WHERE EXTRACT(YEAR FROM holiday_date) = %s"
|
||||||
|
params.append(year)
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM vacation_holidays {clause} ORDER BY holiday_date ASC",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
return [self._holiday_serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def red_holiday_set(self, *, date_from: str, date_to: str) -> set[str]:
|
||||||
|
"""[date_from, date_to] 범위의 is_red=TRUE 공휴일 ISO 날짜 집합.
|
||||||
|
|
||||||
|
휴가일수 계산/달력 색상의 단일 진실 공급원.
|
||||||
|
"""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT holiday_date FROM vacation_holidays "
|
||||||
|
"WHERE is_red = TRUE AND holiday_date BETWEEN %s AND %s",
|
||||||
|
(date_from, date_to),
|
||||||
|
).fetchall()
|
||||||
|
out: set[str] = set()
|
||||||
|
for r in rows:
|
||||||
|
d = r["holiday_date"]
|
||||||
|
out.add(d.isoformat() if isinstance(d, date) else str(d))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def upsert_holiday(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
holiday_date: str,
|
||||||
|
name: str,
|
||||||
|
kind: str = "public",
|
||||||
|
is_red: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
hd = (holiday_date or "").strip()
|
||||||
|
nm = (name or "").strip()
|
||||||
|
if not hd or not nm:
|
||||||
|
raise ValueError("공휴일 날짜와 이름은 필수입니다.")
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO vacation_holidays (holiday_date, name, kind, is_red)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
ON CONFLICT (holiday_date) DO UPDATE
|
||||||
|
SET name = EXCLUDED.name,
|
||||||
|
kind = EXCLUDED.kind,
|
||||||
|
is_red = EXCLUDED.is_red
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(hd, nm, (kind or "public").strip(), bool(is_red)),
|
||||||
|
).fetchone()
|
||||||
|
return self._holiday_serialize(row)
|
||||||
|
|
||||||
|
def delete_holiday(self, *, holiday_id: int) -> None:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM vacation_holidays WHERE id = %s", (holiday_id,)
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise KeyError(holiday_id)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 잔여 연차 (vacation_balances)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def get_balance(self, *, user_email: str, year: int) -> dict[str, Any] | None:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM vacation_balances WHERE user_email = %s AND year = %s",
|
||||||
|
(user_email.lower().strip(), year),
|
||||||
|
).fetchone()
|
||||||
|
return self._balance_serialize(row) if row else None
|
||||||
|
|
||||||
|
def list_balances(self, *, year: int) -> list[dict[str, Any]]:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM vacation_balances WHERE year = %s ORDER BY user_email ASC",
|
||||||
|
(year,),
|
||||||
|
).fetchall()
|
||||||
|
return [self._balance_serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def upsert_balance(
|
||||||
|
self, *, user_email: str, year: int, total_days: float, memo: str = ""
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
email = (user_email or "").lower().strip()
|
||||||
|
if not email or "@" not in email:
|
||||||
|
raise ValueError("올바른 이메일이 필요합니다.")
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO vacation_balances (user_email, year, total_days, memo)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
ON CONFLICT (user_email, year) DO UPDATE
|
||||||
|
SET total_days = EXCLUDED.total_days,
|
||||||
|
memo = EXCLUDED.memo
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(email, year, total_days, (memo or "").strip()),
|
||||||
|
).fetchone()
|
||||||
|
return self._balance_serialize(row)
|
||||||
|
|
||||||
|
def used_days(self, *, user_email: str, year: int) -> float:
|
||||||
|
"""해당 연도 승인된 휴가의 합계 일수(사용 연차). start_date 연도 기준."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COALESCE(SUM(days), 0) AS s FROM vacation_requests "
|
||||||
|
"WHERE owner = %s AND status = '승인' "
|
||||||
|
"AND EXTRACT(YEAR FROM start_date) = %s",
|
||||||
|
(user_email.lower().strip(), year),
|
||||||
|
).fetchone()
|
||||||
|
return float(row["s"]) if row else 0.0
|
||||||
|
|
||||||
|
def balance_summary(self, *, user_email: str, year: int) -> dict[str, Any]:
|
||||||
|
bal = self.get_balance(user_email=user_email, year=year)
|
||||||
|
total = float(bal["total_days"]) if bal else 0.0
|
||||||
|
used = self.used_days(user_email=user_email, year=year)
|
||||||
|
return {
|
||||||
|
"year": year,
|
||||||
|
"total_days": total,
|
||||||
|
"used_days": used,
|
||||||
|
"remaining_days": round(total - used, 2),
|
||||||
|
"memo": bal["memo"] if bal else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 휴가 신청 (vacation_requests)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def list_for(self, owner: str) -> list[dict[str, Any]]:
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM vacation_requests WHERE owner = %s "
|
||||||
|
"ORDER BY start_date DESC, created_at DESC",
|
||||||
|
(owner,),
|
||||||
|
).fetchall()
|
||||||
|
return [self._req_serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def list_overlapping(self, *, date_from: str, date_to: str) -> list[dict[str, Any]]:
|
||||||
|
"""[date_from, date_to] 와 겹치는 모든 신청(달력 bar 표시용).
|
||||||
|
|
||||||
|
취소 포함(흐리게 표시). 범위가 한 칸이라도 겹치면 포함.
|
||||||
|
"""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM vacation_requests "
|
||||||
|
"WHERE start_date <= %s AND end_date >= %s "
|
||||||
|
"ORDER BY start_date ASC, owner ASC",
|
||||||
|
(date_to, date_from),
|
||||||
|
).fetchall()
|
||||||
|
return [self._req_serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def get_request(self, *, request_id: str) -> dict[str, Any] | None:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM vacation_requests WHERE id = %s", (request_id,)
|
||||||
|
).fetchone()
|
||||||
|
return self._req_serialize(row) if row else None
|
||||||
|
|
||||||
|
def create_request(
|
||||||
|
self, *, owner: str, owner_name: str, payload: dict[str, Any], status: str = "작성중"
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
h = self._normalize_payload(payload)
|
||||||
|
if status not in STATUSES:
|
||||||
|
status = "작성중"
|
||||||
|
days = self._calc_days(h)
|
||||||
|
req_id = uuid.uuid4().hex[:12]
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO vacation_requests
|
||||||
|
(id, owner, owner_name, vacation_type, start_date, end_date,
|
||||||
|
start_half, end_half, days, reason, status)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
req_id,
|
||||||
|
owner.lower().strip(),
|
||||||
|
(owner_name or "").strip(),
|
||||||
|
h["vacation_type"],
|
||||||
|
h["start_date"],
|
||||||
|
h["end_date"],
|
||||||
|
h["start_half"],
|
||||||
|
h["end_half"],
|
||||||
|
days,
|
||||||
|
h["reason"],
|
||||||
|
status,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return self._req_serialize(row)
|
||||||
|
|
||||||
|
def update_request(
|
||||||
|
self, *, request_id: str, owner: str, payload: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""작성중/반려 상태에서만 본인이 수정 가능."""
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
current = self.get_request(request_id=request_id)
|
||||||
|
if not current:
|
||||||
|
raise KeyError(request_id)
|
||||||
|
if current["owner"] != owner:
|
||||||
|
raise PermissionError("본인 신청만 수정할 수 있습니다.")
|
||||||
|
if current["status"] not in EDITABLE_STATUSES:
|
||||||
|
raise ValueError(f"{current['status']} 상태에서는 수정할 수 없습니다.")
|
||||||
|
h = self._normalize_payload(payload)
|
||||||
|
days = self._calc_days(h)
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE vacation_requests
|
||||||
|
SET vacation_type = %s, start_date = %s, end_date = %s,
|
||||||
|
start_half = %s, end_half = %s, days = %s, reason = %s,
|
||||||
|
status = '작성중', reject_reason = '',
|
||||||
|
approver_email = NULL, decided_at = NULL
|
||||||
|
WHERE id = %s AND owner = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
h["vacation_type"],
|
||||||
|
h["start_date"],
|
||||||
|
h["end_date"],
|
||||||
|
h["start_half"],
|
||||||
|
h["end_half"],
|
||||||
|
days,
|
||||||
|
h["reason"],
|
||||||
|
request_id,
|
||||||
|
owner,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(request_id)
|
||||||
|
return self._req_serialize(row)
|
||||||
|
|
||||||
|
# ── 워크플로 ──
|
||||||
|
def submit(self, *, request_id: str, owner: str) -> dict[str, Any]:
|
||||||
|
"""작성중/반려 → 제출 (본인)."""
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE vacation_requests
|
||||||
|
SET status = '제출', reject_reason = '',
|
||||||
|
approver_email = NULL, decided_at = NULL
|
||||||
|
WHERE id = %s AND owner = %s AND status IN ('작성중', '반려')
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(request_id, owner),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("작성중/반려 상태에서만 제출할 수 있습니다.")
|
||||||
|
return self._req_serialize(row)
|
||||||
|
|
||||||
|
def approve(self, *, request_id: str, approver_email: str) -> dict[str, Any]:
|
||||||
|
approver_email = approver_email.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE vacation_requests
|
||||||
|
SET status = '승인', approver_email = %s, decided_at = now(),
|
||||||
|
reject_reason = ''
|
||||||
|
WHERE id = %s AND status = '제출'
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(approver_email, request_id),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("제출 상태가 아니거나 신청이 없습니다.")
|
||||||
|
return self._req_serialize(row)
|
||||||
|
|
||||||
|
def reject(
|
||||||
|
self, *, request_id: str, approver_email: str, reason: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not (reason or "").strip():
|
||||||
|
raise ValueError("반려 사유는 필수입니다.")
|
||||||
|
approver_email = approver_email.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE vacation_requests
|
||||||
|
SET status = '반려', approver_email = %s, decided_at = now(),
|
||||||
|
reject_reason = %s
|
||||||
|
WHERE id = %s AND status = '제출'
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(approver_email, reason.strip(), request_id),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("제출 상태가 아니거나 신청이 없습니다.")
|
||||||
|
return self._req_serialize(row)
|
||||||
|
|
||||||
|
def cancel(self, *, request_id: str, user_email: str, is_admin: bool) -> dict[str, Any]:
|
||||||
|
"""soft delete — status='취소'. owner 또는 admin 만."""
|
||||||
|
user_email = user_email.lower().strip()
|
||||||
|
current = self.get_request(request_id=request_id)
|
||||||
|
if not current:
|
||||||
|
raise KeyError(request_id)
|
||||||
|
if not is_admin and current["owner"] != user_email:
|
||||||
|
raise PermissionError("본인 신청만 취소할 수 있습니다.")
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"UPDATE vacation_requests SET status = '취소' WHERE id = %s RETURNING *",
|
||||||
|
(request_id,),
|
||||||
|
).fetchone()
|
||||||
|
return self._req_serialize(row)
|
||||||
|
|
||||||
|
def hard_delete(self, *, request_id: str, user_email: str, is_admin: bool) -> None:
|
||||||
|
"""완전 삭제.
|
||||||
|
|
||||||
|
- 관리자(is_admin): 모든 상태 삭제 가능.
|
||||||
|
- 일반 사용자: 본인 + 승인 전(작성중/제출/반려)만. 승인/취소 건은 불가.
|
||||||
|
"""
|
||||||
|
user_email = user_email.lower().strip()
|
||||||
|
current = self.get_request(request_id=request_id)
|
||||||
|
if not current:
|
||||||
|
raise KeyError(request_id)
|
||||||
|
if not is_admin:
|
||||||
|
if current["owner"] != user_email:
|
||||||
|
raise PermissionError("본인 신청만 삭제할 수 있습니다.")
|
||||||
|
if current["status"] not in ("작성중", "제출", "반려"):
|
||||||
|
raise ValueError("승인/취소된 휴가는 삭제할 수 없습니다. (관리자 문의)")
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM vacation_requests WHERE id = %s", (request_id,)
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise KeyError(request_id)
|
||||||
|
|
||||||
|
def list_pending(self) -> list[dict[str, Any]]:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM vacation_requests WHERE status = '제출' "
|
||||||
|
"ORDER BY start_date ASC, created_at ASC"
|
||||||
|
).fetchall()
|
||||||
|
return [self._req_serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def list_for_export(
|
||||||
|
self, *, date_from: str | None = None, date_to: str | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if date_from:
|
||||||
|
clauses.append("end_date >= %s")
|
||||||
|
params.append(date_from)
|
||||||
|
if date_to:
|
||||||
|
clauses.append("start_date <= %s")
|
||||||
|
params.append(date_to)
|
||||||
|
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM vacation_requests {where} "
|
||||||
|
"ORDER BY start_date ASC, owner ASC",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
return [self._req_serialize(r) for r in rows]
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 정규화 / 계산 / 직렬화 helpers
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def _calc_days(self, h: dict[str, Any]) -> float:
|
||||||
|
holidays = self.red_holiday_set(
|
||||||
|
date_from=h["start_date"], date_to=h["end_date"]
|
||||||
|
)
|
||||||
|
return compute_days(
|
||||||
|
start_date=date.fromisoformat(h["start_date"]),
|
||||||
|
end_date=date.fromisoformat(h["end_date"]),
|
||||||
|
start_half=h["start_half"],
|
||||||
|
end_half=h["end_half"],
|
||||||
|
vacation_type=h["vacation_type"],
|
||||||
|
holidays=holidays,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
vtype = str(payload.get("vacation_type") or "연차").strip()
|
||||||
|
if vtype not in VACATION_TYPES:
|
||||||
|
vtype = "연차"
|
||||||
|
start_date = str(payload.get("start_date") or "").strip()
|
||||||
|
end_date = str(payload.get("end_date") or "").strip()
|
||||||
|
if not start_date:
|
||||||
|
raise ValueError("시작일은 필수입니다.")
|
||||||
|
if not end_date:
|
||||||
|
end_date = start_date
|
||||||
|
# 날짜 형식/순서 검증
|
||||||
|
try:
|
||||||
|
sd = date.fromisoformat(start_date)
|
||||||
|
ed = date.fromisoformat(end_date)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("날짜 형식이 올바르지 않습니다 (YYYY-MM-DD).")
|
||||||
|
if ed < sd:
|
||||||
|
raise ValueError("종료일이 시작일보다 빠를 수 없습니다.")
|
||||||
|
start_half = normalize_half(payload.get("start_half"))
|
||||||
|
end_half = normalize_half(payload.get("end_half"))
|
||||||
|
# 반차 종류면 단일 일자로 강제
|
||||||
|
if vtype in ("오전반차", "오후반차"):
|
||||||
|
end_date = start_date
|
||||||
|
start_half = "am" if vtype == "오전반차" else "pm"
|
||||||
|
end_half = start_half
|
||||||
|
return {
|
||||||
|
"vacation_type": vtype,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"start_half": start_half,
|
||||||
|
"end_half": end_half,
|
||||||
|
"reason": str(payload.get("reason") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _num(v: Any) -> float:
|
||||||
|
if isinstance(v, Decimal):
|
||||||
|
return float(v)
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _req_serialize(cls, row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
out = dict(row)
|
||||||
|
for k in ("start_date", "end_date"):
|
||||||
|
v = out.get(k)
|
||||||
|
if isinstance(v, date):
|
||||||
|
out[k] = v.isoformat()
|
||||||
|
for k in ("created_at", "updated_at", "decided_at"):
|
||||||
|
v = out.get(k)
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||||
|
out["days"] = cls._num(out.get("days", 0))
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _holiday_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
out = dict(row)
|
||||||
|
out["id"] = int(out["id"])
|
||||||
|
v = out.get("holiday_date")
|
||||||
|
if isinstance(v, date):
|
||||||
|
out["holiday_date"] = v.isoformat()
|
||||||
|
out["is_red"] = bool(out.get("is_red", True))
|
||||||
|
for k in ("created_at", "updated_at"):
|
||||||
|
v = out.get(k)
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||||
|
return out
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _balance_serialize(cls, row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
out = dict(row)
|
||||||
|
out["id"] = int(out["id"])
|
||||||
|
out["year"] = int(out["year"])
|
||||||
|
out["total_days"] = cls._num(out.get("total_days", 0))
|
||||||
|
out["used_days"] = cls._num(out.get("used_days", 0))
|
||||||
|
for k in ("created_at", "updated_at"):
|
||||||
|
v = out.get(k)
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||||
|
return out
|
||||||
@@ -0,0 +1,827 @@
|
|||||||
|
"""휴가 관리 모듈 라우터.
|
||||||
|
|
||||||
|
- 경로: /vacation
|
||||||
|
- 권한: 로그인 + `vacation` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
|
||||||
|
승인/반려는 `vacation_approver` 또는 admin.
|
||||||
|
- 데이터: VacationDBStore (vacation_db / PostgreSQL) 전용.
|
||||||
|
VACATION_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내를 보여준다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar as _calendar
|
||||||
|
import io
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||||
|
from fastapi.responses import (
|
||||||
|
HTMLResponse,
|
||||||
|
JSONResponse,
|
||||||
|
RedirectResponse,
|
||||||
|
StreamingResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.timezone import now_kst, today_kst
|
||||||
|
|
||||||
|
from .store import HALF_LABELS, HALVES, VACATION_TYPES
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/vacation", tags=["vacation"])
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 공용 헬퍼
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
def _store(request: Request) -> Any:
|
||||||
|
return getattr(request.app.state, "vacation_store", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_user(request: Request) -> dict[str, Any]:
|
||||||
|
from app.main import get_current_user_record # noqa: WPS433
|
||||||
|
from app.store import has_module # noqa: WPS433
|
||||||
|
|
||||||
|
user = get_current_user_record(request)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||||
|
if not has_module(user, "vacation"):
|
||||||
|
raise HTTPException(status_code=403, detail="휴가 관리 모듈 권한이 없습니다.")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _require_approver(request: Request) -> dict[str, Any]:
|
||||||
|
from app.main import get_current_user_record # noqa: WPS433
|
||||||
|
from app.store import has_module, is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
user = get_current_user_record(request)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||||
|
if not (is_admin(user) or has_module(user, "vacation_approver")):
|
||||||
|
raise HTTPException(status_code=403, detail="휴가 승인자 권한이 필요합니다.")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _is_approver(request: Request, user: dict[str, Any]) -> bool:
|
||||||
|
from app.store import has_module, is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
return is_admin(user) or has_module(user, "vacation_approver")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
|
||||||
|
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"denied.html",
|
||||||
|
{
|
||||||
|
"reason": "휴가 관리 모듈이 아직 설정되지 않았습니다. "
|
||||||
|
"VACATION_DB_URL 환경변수를 설정하고 scripts/sql/vacation_db_init.sql 로 "
|
||||||
|
"vacation_db 를 초기화한 뒤 컨테이너를 재기동하세요.",
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"nav_items": build_erp_nav(user, active="vacation"),
|
||||||
|
},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _guard(
|
||||||
|
request: Request,
|
||||||
|
) -> tuple[Any, dict[str, Any]] | HTMLResponse | RedirectResponse:
|
||||||
|
"""로그인+권한+store 점검. 페이지 핸들러 진입부에서 사용."""
|
||||||
|
from app.main import get_current_user_record, render_template # noqa: WPS433
|
||||||
|
from app.store import has_module, is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
user = get_current_user_record(request)
|
||||||
|
if user is None:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
if not has_module(user, "vacation"):
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"denied.html",
|
||||||
|
{"reason": "휴가 관리 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
return _render_config_needed(request, user)
|
||||||
|
return store, user
|
||||||
|
|
||||||
|
|
||||||
|
def _ym(request: Request) -> tuple[int, int]:
|
||||||
|
today = today_kst()
|
||||||
|
try:
|
||||||
|
year = int(request.query_params.get("year") or today.year)
|
||||||
|
month = int(request.query_params.get("month") or today.month)
|
||||||
|
except ValueError:
|
||||||
|
year, month = today.year, today.month
|
||||||
|
if not (1 <= month <= 12):
|
||||||
|
year, month = today.year, today.month
|
||||||
|
return year, month
|
||||||
|
|
||||||
|
|
||||||
|
# 상태별 bar 스타일 클래스 (CSS 와 매핑)
|
||||||
|
_STATUS_CLASS = {
|
||||||
|
"작성중": "vac-bar-draft",
|
||||||
|
"제출": "vac-bar-submit",
|
||||||
|
"승인": "vac-bar-approve",
|
||||||
|
"반려": "vac-bar-reject",
|
||||||
|
"취소": "vac-bar-cancel",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_lanes(bars: list[dict[str, Any]]) -> int:
|
||||||
|
"""주(week) 내 bar 들에 겹치지 않는 lane(행) 번호를 그리디 배정.
|
||||||
|
|
||||||
|
bars 는 같은 주의 segment 들. 각 bar 에 'lane' 키를 추가하고, 사용된 lane 수를 반환.
|
||||||
|
"""
|
||||||
|
bars.sort(key=lambda b: (b["start_col"], -b["span"]))
|
||||||
|
lane_end: list[int] = [] # lane 별 마지막 점유 col(포함)
|
||||||
|
for b in bars:
|
||||||
|
placed = False
|
||||||
|
for li, end_col in enumerate(lane_end):
|
||||||
|
if b["start_col"] > end_col:
|
||||||
|
b["lane"] = li
|
||||||
|
lane_end[li] = b["start_col"] + b["span"] - 1
|
||||||
|
placed = True
|
||||||
|
break
|
||||||
|
if not placed:
|
||||||
|
b["lane"] = len(lane_end)
|
||||||
|
lane_end.append(b["start_col"] + b["span"] - 1)
|
||||||
|
return len(lane_end)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_calendar(
|
||||||
|
store: Any, year: int, month: int, sel: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""월간 달력 데이터 + bar lane 레이아웃 계산."""
|
||||||
|
cal = _calendar.Calendar(firstweekday=6) # 일요일 시작
|
||||||
|
weeks_dates = cal.monthdatescalendar(year, month)
|
||||||
|
range_start = weeks_dates[0][0]
|
||||||
|
range_end = weeks_dates[-1][-1]
|
||||||
|
|
||||||
|
red_set = store.red_holiday_set(
|
||||||
|
date_from=range_start.isoformat(), date_to=range_end.isoformat()
|
||||||
|
)
|
||||||
|
holiday_names = {
|
||||||
|
h["holiday_date"]: h["name"]
|
||||||
|
for h in store.list_holidays()
|
||||||
|
if h.get("is_red")
|
||||||
|
}
|
||||||
|
requests = store.list_overlapping(
|
||||||
|
date_from=range_start.isoformat(), date_to=range_end.isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
today = today_kst()
|
||||||
|
weeks: list[dict[str, Any]] = []
|
||||||
|
for week in weeks_dates:
|
||||||
|
w_start, w_end = week[0], week[-1]
|
||||||
|
days = [
|
||||||
|
{
|
||||||
|
"date": d.isoformat(),
|
||||||
|
"day": d.day,
|
||||||
|
"in_month": d.month == month,
|
||||||
|
"is_today": d == today,
|
||||||
|
"is_selected": d.isoformat() == sel,
|
||||||
|
"is_sunday": d.weekday() == 6,
|
||||||
|
"is_saturday": d.weekday() == 5,
|
||||||
|
"is_holiday": d.isoformat() in red_set,
|
||||||
|
"holiday_name": holiday_names.get(d.isoformat(), ""),
|
||||||
|
}
|
||||||
|
for d in week
|
||||||
|
]
|
||||||
|
# 이 주에 걸치는 bar segment
|
||||||
|
bars: list[dict[str, Any]] = []
|
||||||
|
for r in requests:
|
||||||
|
rs = date.fromisoformat(r["start_date"])
|
||||||
|
re_ = date.fromisoformat(r["end_date"])
|
||||||
|
if re_ < w_start or rs > w_end:
|
||||||
|
continue
|
||||||
|
seg_start = max(rs, w_start)
|
||||||
|
seg_end = min(re_, w_end)
|
||||||
|
start_col = (seg_start - w_start).days # 0..6
|
||||||
|
span = (seg_end - seg_start).days + 1
|
||||||
|
label = f"{r.get('owner_name') or r.get('owner')} {r['vacation_type']}"
|
||||||
|
bars.append(
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"label": label,
|
||||||
|
"status": r["status"],
|
||||||
|
"status_class": _STATUS_CLASS.get(r["status"], "vac-bar-draft"),
|
||||||
|
"start_col": start_col,
|
||||||
|
"span": span,
|
||||||
|
"continues_left": rs < w_start,
|
||||||
|
"continues_right": re_ > w_end,
|
||||||
|
"days": r["days"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lane_count = _assign_lanes(bars)
|
||||||
|
weeks.append({"days": days, "bars": bars, "lane_count": lane_count})
|
||||||
|
|
||||||
|
return {"weeks": weeks, "requests": requests, "sel_date": sel}
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 메인 — 월간 달력 + 선택일 휴가 리스트
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request) -> HTMLResponse:
|
||||||
|
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
guard = _guard(request)
|
||||||
|
if not isinstance(guard, tuple):
|
||||||
|
return guard
|
||||||
|
store, user = guard
|
||||||
|
|
||||||
|
year, month = _ym(request)
|
||||||
|
sel = request.query_params.get("date") or ""
|
||||||
|
today = today_kst()
|
||||||
|
if not sel:
|
||||||
|
sel = (
|
||||||
|
today.isoformat()
|
||||||
|
if (today.year == year and today.month == month)
|
||||||
|
else f"{year:04d}-{month:02d}-01"
|
||||||
|
)
|
||||||
|
|
||||||
|
caldata = _build_calendar(store, year, month, sel)
|
||||||
|
sel_requests = [
|
||||||
|
r
|
||||||
|
for r in caldata["requests"]
|
||||||
|
if r["start_date"] <= sel <= r["end_date"]
|
||||||
|
]
|
||||||
|
|
||||||
|
prev_y, prev_m = (year - 1, 12) if month == 1 else (year, month - 1)
|
||||||
|
next_y, next_m = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||||
|
|
||||||
|
summary = store.balance_summary(user_email=user["email"], year=year)
|
||||||
|
is_approver = _is_approver(request, user)
|
||||||
|
pending_count = len(store.list_pending()) if is_approver else 0
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"vacation/index.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"is_approver": is_approver,
|
||||||
|
"pending_count": pending_count,
|
||||||
|
"nav_items": build_erp_nav(user, active="vacation"),
|
||||||
|
"page_title": "휴가 관리",
|
||||||
|
"page_subtitle": f"{year}년 {month}월 휴가 달력",
|
||||||
|
"year": year,
|
||||||
|
"month": month,
|
||||||
|
"prev_y": prev_y, "prev_m": prev_m,
|
||||||
|
"next_y": next_y, "next_m": next_m,
|
||||||
|
"today": today.isoformat(),
|
||||||
|
"weekdays": ["일", "월", "화", "수", "목", "금", "토"],
|
||||||
|
"weeks": caldata["weeks"],
|
||||||
|
"selected_date": sel,
|
||||||
|
"sel_requests": sel_requests,
|
||||||
|
"balance": summary,
|
||||||
|
"half_labels": HALF_LABELS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 휴가 신청 — 등록 / 수정 / 상세
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def _form_context(request: Request, user: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
from app.main import build_erp_nav # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"is_approver": _is_approver(request, user),
|
||||||
|
"nav_items": build_erp_nav(user, active="vacation"),
|
||||||
|
"vacation_types": list(VACATION_TYPES),
|
||||||
|
"halves": list(HALVES),
|
||||||
|
"half_labels": HALF_LABELS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/new", response_class=HTMLResponse)
|
||||||
|
async def new_form(request: Request) -> HTMLResponse:
|
||||||
|
from app.main import render_template # noqa: WPS433
|
||||||
|
|
||||||
|
guard = _guard(request)
|
||||||
|
if not isinstance(guard, tuple):
|
||||||
|
return guard
|
||||||
|
_store_obj, user = guard
|
||||||
|
ctx = _form_context(request, user)
|
||||||
|
ctx.update(
|
||||||
|
{
|
||||||
|
"page_title": "휴가 신청",
|
||||||
|
"page_subtitle": "휴가 종류 · 기간 · 사유 입력",
|
||||||
|
"mode": "new",
|
||||||
|
"req": None,
|
||||||
|
"default_date": today_kst().isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return render_template(request, "vacation/form.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_from_form(
|
||||||
|
vacation_type: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
start_half: str,
|
||||||
|
end_half: str,
|
||||||
|
reason: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"vacation_type": vacation_type,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date or start_date,
|
||||||
|
"start_half": start_half,
|
||||||
|
"end_half": end_half,
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/new")
|
||||||
|
async def create(
|
||||||
|
request: Request,
|
||||||
|
vacation_type: str = Form(...),
|
||||||
|
start_date: str = Form(...),
|
||||||
|
end_date: str = Form(""),
|
||||||
|
start_half: str = Form("full"),
|
||||||
|
end_half: str = Form("full"),
|
||||||
|
reason: str = Form(""),
|
||||||
|
action: str = Form("submit"), # "draft" | "submit"
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||||
|
status = "작성중" if action == "draft" else "제출"
|
||||||
|
payload = _payload_from_form(
|
||||||
|
vacation_type, start_date, end_date, start_half, end_half, reason
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
req = store.create_request(
|
||||||
|
owner=user["email"],
|
||||||
|
owner_name=user.get("name") or user["email"],
|
||||||
|
payload=payload,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return RedirectResponse(url=f"/vacation/{req['id']}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending", response_class=HTMLResponse)
|
||||||
|
async def pending_page(request: Request) -> HTMLResponse:
|
||||||
|
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
guard = _guard(request)
|
||||||
|
if not isinstance(guard, tuple):
|
||||||
|
return guard
|
||||||
|
store, user = guard
|
||||||
|
if not _is_approver(request, user):
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"denied.html",
|
||||||
|
{"reason": "휴가 승인자 권한이 없습니다.", "is_admin": is_admin(user)},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
pending = store.list_pending()
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"vacation/pending.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"is_approver": True,
|
||||||
|
"nav_items": build_erp_nav(user, active="vacation"),
|
||||||
|
"page_title": "휴가 — 승인 대기",
|
||||||
|
"page_subtitle": f"제출 상태 {len(pending)}건",
|
||||||
|
"items": pending,
|
||||||
|
"half_labels": HALF_LABELS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_page(request: Request) -> HTMLResponse:
|
||||||
|
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
guard = _guard(request)
|
||||||
|
if not isinstance(guard, tuple):
|
||||||
|
return guard
|
||||||
|
store, user = guard
|
||||||
|
if not is_admin(user):
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"denied.html",
|
||||||
|
{"reason": "휴가 설정은 관리자만 접근할 수 있습니다.", "is_admin": False},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
today = today_kst()
|
||||||
|
try:
|
||||||
|
year = int(request.query_params.get("year") or today.year)
|
||||||
|
except ValueError:
|
||||||
|
year = today.year
|
||||||
|
balances = store.list_balances(year=year)
|
||||||
|
for b in balances:
|
||||||
|
b["used_days"] = store.used_days(user_email=b["user_email"], year=year)
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"vacation/settings.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"is_admin": True,
|
||||||
|
"is_approver": True,
|
||||||
|
"nav_items": build_erp_nav(user, active="vacation"),
|
||||||
|
"page_title": "휴가 — 설정",
|
||||||
|
"page_subtitle": "공휴일 · 연차 잔여 관리",
|
||||||
|
"year": year,
|
||||||
|
"holidays": store.list_holidays(year=year),
|
||||||
|
"balances": balances,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/holidays")
|
||||||
|
async def holiday_upsert(
|
||||||
|
request: Request,
|
||||||
|
holiday_date: str = Form(...),
|
||||||
|
name: str = Form(...),
|
||||||
|
kind: str = Form("public"),
|
||||||
|
is_red: str = Form(""),
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
if not is_admin(user):
|
||||||
|
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||||
|
try:
|
||||||
|
store.upsert_holiday(
|
||||||
|
holiday_date=holiday_date,
|
||||||
|
name=name,
|
||||||
|
kind=kind,
|
||||||
|
is_red=is_red in ("1", "true", "on", "True"),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
year = holiday_date[:4]
|
||||||
|
return RedirectResponse(url=f"/vacation/settings?year={year}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/holidays/{holiday_id:int}/delete")
|
||||||
|
async def holiday_delete(
|
||||||
|
request: Request,
|
||||||
|
holiday_id: int,
|
||||||
|
year: str = Form(""),
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
if not is_admin(user):
|
||||||
|
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||||
|
try:
|
||||||
|
store.delete_holiday(holiday_id=holiday_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="공휴일을 찾을 수 없습니다.")
|
||||||
|
suffix = f"?year={year}" if year else ""
|
||||||
|
return RedirectResponse(url=f"/vacation/settings{suffix}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/balances")
|
||||||
|
async def balance_upsert(
|
||||||
|
request: Request,
|
||||||
|
user_email: str = Form(...),
|
||||||
|
year: int = Form(...),
|
||||||
|
total_days: float = Form(...),
|
||||||
|
memo: str = Form(""),
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
if not is_admin(user):
|
||||||
|
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||||
|
try:
|
||||||
|
store.upsert_balance(
|
||||||
|
user_email=user_email, year=year, total_days=total_days, memo=memo
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return RedirectResponse(url=f"/vacation/settings?year={year}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/calendar")
|
||||||
|
async def api_calendar(
|
||||||
|
request: Request, user: dict[str, Any] = Depends(_require_user)
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""달력 비동기 데이터(JSON). year/month 쿼리."""
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||||
|
year, month = _ym(request)
|
||||||
|
sel = request.query_params.get("date") or f"{year:04d}-{month:02d}-01"
|
||||||
|
caldata = _build_calendar(store, year, month, sel)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"year": year,
|
||||||
|
"month": month,
|
||||||
|
"weeks": caldata["weeks"],
|
||||||
|
"requests": caldata["requests"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export.xlsx")
|
||||||
|
async def export_xlsx(
|
||||||
|
request: Request, user: dict[str, Any] = Depends(_require_user)
|
||||||
|
) -> StreamingResponse:
|
||||||
|
from openpyxl import Workbook # 지연 import
|
||||||
|
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||||
|
|
||||||
|
today = today_kst()
|
||||||
|
try:
|
||||||
|
year = int(request.query_params.get("year") or today.year)
|
||||||
|
month = int(request.query_params.get("month") or 0)
|
||||||
|
except ValueError:
|
||||||
|
year, month = today.year, 0
|
||||||
|
|
||||||
|
if 1 <= month <= 12:
|
||||||
|
last = _calendar.monthrange(year, month)[1]
|
||||||
|
date_from = f"{year:04d}-{month:02d}-01"
|
||||||
|
date_to = f"{year:04d}-{month:02d}-{last:02d}"
|
||||||
|
else:
|
||||||
|
date_from = f"{year:04d}-01-01"
|
||||||
|
date_to = f"{year:04d}-12-31"
|
||||||
|
|
||||||
|
rows = store.list_for_export(date_from=date_from, date_to=date_to)
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "vacation"
|
||||||
|
header = [
|
||||||
|
"신청자", "휴가종류", "시작일", "종료일", "시작구분", "종료구분",
|
||||||
|
"사용일수", "상태", "승인자", "승인/반려일", "사유", "반려사유",
|
||||||
|
]
|
||||||
|
ws.append(header)
|
||||||
|
for r in rows:
|
||||||
|
ws.append([
|
||||||
|
r.get("owner_name") or r.get("owner", ""),
|
||||||
|
r.get("vacation_type", ""),
|
||||||
|
r.get("start_date", ""),
|
||||||
|
r.get("end_date", ""),
|
||||||
|
HALF_LABELS.get(r.get("start_half", "full"), ""),
|
||||||
|
HALF_LABELS.get(r.get("end_half", "full"), ""),
|
||||||
|
r.get("days", 0),
|
||||||
|
r.get("status", ""),
|
||||||
|
r.get("approver_email", "") or "",
|
||||||
|
r.get("decided_at", "") or "",
|
||||||
|
r.get("reason", "") or "",
|
||||||
|
r.get("reject_reason", "") or "",
|
||||||
|
])
|
||||||
|
widths = [24, 10, 12, 12, 8, 8, 8, 8, 24, 22, 30, 30]
|
||||||
|
for col, w in enumerate(widths, start=1):
|
||||||
|
ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = w
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
buf.seek(0)
|
||||||
|
scope = f"{year}{('%02d' % month) if (1 <= month <= 12) else ''}"
|
||||||
|
fname = f"vacation_{scope}_{now_kst().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||||
|
return StreamingResponse(
|
||||||
|
buf,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok", "module": "vacation"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{request_id}", response_class=HTMLResponse)
|
||||||
|
async def detail(request: Request, request_id: str) -> HTMLResponse:
|
||||||
|
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
guard = _guard(request)
|
||||||
|
if not isinstance(guard, tuple):
|
||||||
|
return guard
|
||||||
|
store, user = guard
|
||||||
|
req = store.get_request(request_id=request_id)
|
||||||
|
if not req:
|
||||||
|
return render_template(
|
||||||
|
request, "denied.html",
|
||||||
|
{"reason": "휴가 신청을 찾을 수 없습니다.", "is_admin": is_admin(user)},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
is_owner = req["owner"] == user["email"]
|
||||||
|
is_approver = _is_approver(request, user)
|
||||||
|
if not (is_owner or is_approver):
|
||||||
|
return render_template(
|
||||||
|
request, "denied.html",
|
||||||
|
{"reason": "본인 또는 승인자만 조회할 수 있습니다.", "is_admin": is_admin(user)},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"vacation/detail.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"is_approver": is_approver,
|
||||||
|
"is_owner": is_owner,
|
||||||
|
"nav_items": build_erp_nav(user, active="vacation"),
|
||||||
|
"page_title": "휴가 상세",
|
||||||
|
"page_subtitle": f"{req['start_date']} · {req['vacation_type']}",
|
||||||
|
"req": req,
|
||||||
|
"half_labels": HALF_LABELS,
|
||||||
|
"can_edit": is_owner and req["status"] in ("작성중", "반려"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{request_id}/edit", response_class=HTMLResponse)
|
||||||
|
async def edit_form(request: Request, request_id: str) -> HTMLResponse:
|
||||||
|
from app.main import render_template # noqa: WPS433
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
guard = _guard(request)
|
||||||
|
if not isinstance(guard, tuple):
|
||||||
|
return guard
|
||||||
|
store, user = guard
|
||||||
|
req = store.get_request(request_id=request_id)
|
||||||
|
if not req:
|
||||||
|
return render_template(
|
||||||
|
request, "denied.html",
|
||||||
|
{"reason": "휴가 신청을 찾을 수 없습니다.", "is_admin": is_admin(user)},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
if req["owner"] != user["email"]:
|
||||||
|
return render_template(
|
||||||
|
request, "denied.html",
|
||||||
|
{"reason": "본인 신청만 수정할 수 있습니다.", "is_admin": is_admin(user)},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
if req["status"] not in ("작성중", "반려"):
|
||||||
|
return render_template(
|
||||||
|
request, "denied.html",
|
||||||
|
{"reason": f"{req['status']} 상태에서는 수정할 수 없습니다.", "is_admin": is_admin(user)},
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
ctx = _form_context(request, user)
|
||||||
|
ctx.update(
|
||||||
|
{
|
||||||
|
"page_title": "휴가 신청 수정",
|
||||||
|
"page_subtitle": "작성중/반려 상태만 수정 가능",
|
||||||
|
"mode": "edit",
|
||||||
|
"req": req,
|
||||||
|
"default_date": req["start_date"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return render_template(request, "vacation/form.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/edit")
|
||||||
|
async def update(
|
||||||
|
request: Request,
|
||||||
|
request_id: str,
|
||||||
|
vacation_type: str = Form(...),
|
||||||
|
start_date: str = Form(...),
|
||||||
|
end_date: str = Form(""),
|
||||||
|
start_half: str = Form("full"),
|
||||||
|
end_half: str = Form("full"),
|
||||||
|
reason: str = Form(""),
|
||||||
|
action: str = Form("save"), # "save" | "submit"
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||||
|
payload = _payload_from_form(
|
||||||
|
vacation_type, start_date, end_date, start_half, end_half, reason
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
store.update_request(request_id=request_id, owner=user["email"], payload=payload)
|
||||||
|
if action == "submit":
|
||||||
|
store.submit(request_id=request_id, owner=user["email"])
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.")
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/submit")
|
||||||
|
async def submit(
|
||||||
|
request: Request,
|
||||||
|
request_id: str,
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||||
|
try:
|
||||||
|
store.submit(request_id=request_id, owner=user["email"])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc))
|
||||||
|
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/approve")
|
||||||
|
async def approve(
|
||||||
|
request: Request,
|
||||||
|
request_id: str,
|
||||||
|
user: dict[str, Any] = Depends(_require_approver),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||||
|
try:
|
||||||
|
store.approve(request_id=request_id, approver_email=user["email"])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc))
|
||||||
|
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/reject")
|
||||||
|
async def reject(
|
||||||
|
request: Request,
|
||||||
|
request_id: str,
|
||||||
|
reject_reason: str = Form(...),
|
||||||
|
user: dict[str, Any] = Depends(_require_approver),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||||
|
try:
|
||||||
|
store.reject(
|
||||||
|
request_id=request_id, approver_email=user["email"], reason=reject_reason
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc))
|
||||||
|
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/cancel")
|
||||||
|
async def cancel(
|
||||||
|
request: Request,
|
||||||
|
request_id: str,
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||||
|
try:
|
||||||
|
store.cancel(
|
||||||
|
request_id=request_id, user_email=user["email"], is_admin=is_admin(user)
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.")
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc))
|
||||||
|
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/delete")
|
||||||
|
async def delete(
|
||||||
|
request: Request,
|
||||||
|
request_id: str,
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
"""완전 삭제 — 승인 전(작성중/제출/반려)만. 달력으로 복귀."""
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||||
|
try:
|
||||||
|
store.hard_delete(
|
||||||
|
request_id=request_id, user_email=user["email"], is_admin=is_admin(user)
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.")
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc))
|
||||||
|
return RedirectResponse(url="/vacation/", status_code=303)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""휴가 관리 모듈 상수 및 순수 계산 헬퍼.
|
||||||
|
|
||||||
|
- 데이터 저장은 vacation_db(PostgreSQL) 전용이다(`db.py`).
|
||||||
|
운영 데이터가 JSON 과 DB 로 갈라지는 것을 막기 위해 JSON 폴백을 두지 않는다.
|
||||||
|
VACATION_DB_URL 미설정 시 라우터가 "설정 필요" 안내 페이지를 보여준다.
|
||||||
|
- 이 모듈에는 상수와 순수 계산 헬퍼(휴가일수 계산)만 둔다.
|
||||||
|
공휴일 집합은 db.py 가 vacation_holidays 에서 읽어 넘겨준다(holiday provider 분리).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
# 휴가 종류 (한글 라벨 그대로 저장)
|
||||||
|
VACATION_TYPES: tuple[str, ...] = (
|
||||||
|
"연차",
|
||||||
|
"오전반차",
|
||||||
|
"오후반차",
|
||||||
|
"병가",
|
||||||
|
"경조",
|
||||||
|
"대체휴무",
|
||||||
|
"기타",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 신청 상태 워크플로
|
||||||
|
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "취소")
|
||||||
|
|
||||||
|
# 시작/종료일 구분
|
||||||
|
HALVES: tuple[str, ...] = ("full", "am", "pm")
|
||||||
|
HALF_LABELS: dict[str, str] = {"full": "종일", "am": "오전", "pm": "오후"}
|
||||||
|
|
||||||
|
# 수정/삭제(취소) 가능한 상태 — 본인 편집 허용
|
||||||
|
EDITABLE_STATUSES: tuple[str, ...] = ("작성중", "반려")
|
||||||
|
|
||||||
|
# 반차 성격의 휴가 종류 (단일 일자 0.5일 강제)
|
||||||
|
_HALF_DAY_TYPES: dict[str, str] = {"오전반차": "am", "오후반차": "pm"}
|
||||||
|
|
||||||
|
|
||||||
|
def _daterange(start: date, end: date) -> Iterable[date]:
|
||||||
|
cur = start
|
||||||
|
while cur <= end:
|
||||||
|
yield cur
|
||||||
|
cur += timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def is_working_day(d: date, holidays: set[str]) -> bool:
|
||||||
|
"""주말(토/일)과 공휴일(holidays: ISO 날짜 집합)을 제외하면 근무일."""
|
||||||
|
if d.weekday() >= 5: # 5=토, 6=일
|
||||||
|
return False
|
||||||
|
return d.isoformat() not in holidays
|
||||||
|
|
||||||
|
|
||||||
|
def compute_days(
|
||||||
|
*,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
start_half: str = "full",
|
||||||
|
end_half: str = "full",
|
||||||
|
vacation_type: str = "연차",
|
||||||
|
holidays: set[str] | None = None,
|
||||||
|
) -> float:
|
||||||
|
"""휴가 일수 계산. 서버에서 항상 이 함수로 재계산한다(클라이언트 신뢰 금지).
|
||||||
|
|
||||||
|
- 주말 + 공휴일(is_red) 제외.
|
||||||
|
- 오전/오후 반차는 0.5일.
|
||||||
|
- 같은 날 + 반차면 0.5일.
|
||||||
|
- 여러 날에서 시작/종료가 반차면 시작일/종료일 각각 0.5 차감.
|
||||||
|
- 휴가 종류가 오전반차/오후반차면 단일 일자 0.5일로 강제.
|
||||||
|
"""
|
||||||
|
holidays = holidays or set()
|
||||||
|
if end_date < start_date:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# 반차 종류는 단일 일자 0.5
|
||||||
|
if vacation_type in _HALF_DAY_TYPES:
|
||||||
|
if is_working_day(start_date, holidays):
|
||||||
|
return 0.5
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
working = [d for d in _daterange(start_date, end_date) if is_working_day(d, holidays)]
|
||||||
|
n = len(working)
|
||||||
|
if n == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
if start_date == end_date:
|
||||||
|
return 0.5 if start_half in ("am", "pm") else 1.0
|
||||||
|
|
||||||
|
total = float(n)
|
||||||
|
if start_half in ("am", "pm") and is_working_day(start_date, holidays):
|
||||||
|
total -= 0.5
|
||||||
|
if end_half in ("am", "pm") and is_working_day(end_date, holidays):
|
||||||
|
total -= 0.5
|
||||||
|
return max(0.0, total)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_half(value: str | None) -> str:
|
||||||
|
v = (value or "full").strip().lower()
|
||||||
|
return v if v in HALVES else "full"
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530j" />{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="vac">
|
||||||
|
<div class="erp-card vac-detail-card">
|
||||||
|
|
||||||
|
<div class="vac-detail-head">
|
||||||
|
<div>
|
||||||
|
<h2>{{ req.owner_name or req.owner }}</h2>
|
||||||
|
<span class="erp-muted">{{ req.owner }}</span>
|
||||||
|
</div>
|
||||||
|
{% set badge = 'erp-badge-neutral' %}
|
||||||
|
{% if req.status == '승인' %}{% set badge = 'erp-badge-success' %}
|
||||||
|
{% elif req.status == '제출' %}{% set badge = 'erp-badge-inverse' %}
|
||||||
|
{% elif req.status == '반려' %}{% set badge = 'erp-badge-danger' %}{% endif %}
|
||||||
|
<span class="erp-badge {{ badge }} vac-detail-status">{{ req.status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="vac-detail-grid">
|
||||||
|
<div><dt>휴가 종류</dt><dd>{{ req.vacation_type }}</dd></div>
|
||||||
|
<div><dt>사용 일수</dt><dd><strong>{{ req.days }}</strong>일</dd></div>
|
||||||
|
<div><dt>시작일</dt><dd>{{ req.start_date }} ({{ half_labels[req.start_half] }})</dd></div>
|
||||||
|
<div><dt>종료일</dt><dd>{{ req.end_date }} ({{ half_labels[req.end_half] }})</dd></div>
|
||||||
|
{% if req.approver_email %}
|
||||||
|
<div><dt>승인자</dt><dd>{{ req.approver_email }}</dd></div>
|
||||||
|
<div><dt>결재일시</dt><dd>{{ req.decided_at }}</dd></div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{% if req.reason %}
|
||||||
|
<div class="vac-detail-block">
|
||||||
|
<div class="vac-detail-label">사유</div>
|
||||||
|
<p>{{ req.reason }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if req.status == '반려' and req.reject_reason %}
|
||||||
|
<div class="vac-detail-block vac-reject-block">
|
||||||
|
<div class="vac-detail-label">반려 사유</div>
|
||||||
|
<p>{{ req.reject_reason }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- ── 승인자 액션 (제출 상태) ── -->
|
||||||
|
{% if is_approver and req.status == '제출' %}
|
||||||
|
<div class="vac-approve-box">
|
||||||
|
<form method="post" action="/vacation/{{ req.id }}/approve" class="vac-inline-form">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary">승인</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/vacation/{{ req.id }}/reject" class="vac-reject-form">
|
||||||
|
<input class="erp-input" type="text" name="reject_reason" placeholder="반려 사유" required />
|
||||||
|
<button type="submit" class="erp-btn erp-btn-danger">반려</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="erp-page-actions vac-detail-actions">
|
||||||
|
<a class="erp-btn erp-btn-primary" href="/vacation/">◀◀ 달력</a>
|
||||||
|
|
||||||
|
{% if is_owner and req.status in ('작성중', '반려') %}
|
||||||
|
<form method="post" action="/vacation/{{ req.id }}/submit" class="vac-inline-form">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline">제출</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# 승인 건: 취소처리(owner/admin). 미승인: 삭제(owner/admin). 관리자는 모든 상태 삭제 가능. #}
|
||||||
|
{% if req.status == '승인' and (is_owner or is_admin) %}
|
||||||
|
<form method="post" action="/vacation/{{ req.id }}/cancel" class="vac-inline-form"
|
||||||
|
onsubmit="return confirm('승인된 휴가를 취소 처리할까요?');">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline">취소처리</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if req.status in ('작성중', '제출', '반려') and (is_owner or is_admin) %}
|
||||||
|
<form method="post" action="/vacation/{{ req.id }}/delete" class="vac-inline-form"
|
||||||
|
onsubmit="return confirm('이 휴가 신청을 삭제할까요? (복구 불가)');">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_admin and req.status in ('승인', '취소') %}
|
||||||
|
<form method="post" action="/vacation/{{ req.id }}/delete" class="vac-inline-form"
|
||||||
|
onsubmit="return confirm('[관리자] 이 휴가({{ req.status }})를 완전 삭제할까요? (복구 불가)');">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<a class="erp-btn erp-btn-outline vac-push-right" href="/vacation/{{ req.id }}/edit">수정</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="vac">
|
||||||
|
|
||||||
|
{% set action = '/vacation/new' if mode == 'new' else '/vacation/' ~ req.id ~ '/edit' %}
|
||||||
|
<form id="vac-form" method="post" action="{{ action }}" class="erp-card vac-form-card">
|
||||||
|
|
||||||
|
<div class="vac-form-grid">
|
||||||
|
<label class="erp-field"><span>휴가 종류 *</span>
|
||||||
|
<select class="erp-select" name="vacation_type" id="vac-type" required>
|
||||||
|
{% for t in vacation_types %}
|
||||||
|
<option value="{{ t }}" {% if req and req.vacation_type == t %}selected{% endif %}>{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="erp-field"><span>시작일 *</span>
|
||||||
|
<input class="erp-input" type="date" name="start_date" id="vac-start" required
|
||||||
|
value="{{ req.start_date if req else default_date }}" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="erp-field" id="vac-start-half-field"><span>시작 구분</span>
|
||||||
|
<select class="erp-select" name="start_half" id="vac-start-half">
|
||||||
|
{% for h in halves %}
|
||||||
|
<option value="{{ h }}" {% if req and req.start_half == h %}selected{% endif %}>{{ half_labels[h] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="erp-field" id="vac-end-field"><span>종료일 *</span>
|
||||||
|
<input class="erp-input" type="date" name="end_date" id="vac-end"
|
||||||
|
value="{{ req.end_date if req else default_date }}" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="erp-field" id="vac-end-half-field"><span>종료 구분</span>
|
||||||
|
<select class="erp-select" name="end_half" id="vac-end-half">
|
||||||
|
{% for h in halves %}
|
||||||
|
<option value="{{ h }}" {% if req and req.end_half == h %}selected{% endif %}>{{ half_labels[h] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="erp-field vac-full"><span>사유</span>
|
||||||
|
<textarea class="erp-input" name="reason" rows="3"
|
||||||
|
placeholder="휴가 사유를 입력하세요.">{{ req.reason if req else '' }}</textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="vac-days-preview">
|
||||||
|
예상 사용 일수: <strong id="vac-days-out">—</strong>
|
||||||
|
<span class="erp-muted">(주말 제외 · 공휴일은 저장 시 반영)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-page-actions vac-form-actions">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary" name="action" value="submit">제출</button>
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline" name="action"
|
||||||
|
value="{{ 'save' if mode == 'edit' else 'draft' }}">작성중 저장</button>
|
||||||
|
{% if mode == 'edit' %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/vacation/{{ req.id }}">취소</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/vacation/">취소</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}<script src="/static/vacation.js?v=20260530i" defer></script>{% endblock %}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="vac">
|
||||||
|
|
||||||
|
<!-- 페이지 액션 -->
|
||||||
|
<div class="vac-actions">
|
||||||
|
<div class="vac-actions-main">
|
||||||
|
<a class="erp-btn erp-btn-primary" href="/vacation/new">+ 휴가 신청</a>
|
||||||
|
{% if is_approver %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/vacation/pending">승인 대기
|
||||||
|
{% if pending_count %}<span class="erp-badge erp-badge-inverse vac-mini">{{ pending_count }}</span>{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/vacation/export.xlsx?year={{ year }}&month={{ month }}">엑셀</a>
|
||||||
|
{% if is_admin %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/vacation/settings?year={{ year }}">설정</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="vac-balance">
|
||||||
|
<span class="vac-bal-item">총 <strong>{{ balance.total_days }}</strong></span>
|
||||||
|
<span class="vac-bal-sep">·</span>
|
||||||
|
<span class="vac-bal-item">사용 <strong>{{ balance.used_days }}</strong></span>
|
||||||
|
<span class="vac-bal-sep">·</span>
|
||||||
|
<span class="vac-bal-item vac-bal-remain">잔여 <strong>{{ balance.remaining_days }}</strong></span>
|
||||||
|
<span class="erp-muted vac-bal-year">{{ year }}년</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vac-layout">
|
||||||
|
<!-- ── 왼쪽: 월간 달력 ── -->
|
||||||
|
<div class="erp-card vac-cal-card">
|
||||||
|
<div class="vac-cal-head">
|
||||||
|
<a class="erp-btn erp-btn-outline vac-nav-btn"
|
||||||
|
href="/vacation/?year={{ prev_y }}&month={{ prev_m }}">‹</a>
|
||||||
|
<h2 class="vac-cal-title">{{ year }}년 {{ month }}월</h2>
|
||||||
|
<a class="erp-btn erp-btn-outline vac-nav-btn"
|
||||||
|
href="/vacation/?year={{ next_y }}&month={{ next_m }}">›</a>
|
||||||
|
<a class="erp-btn erp-btn-outline vac-today-btn" href="/vacation/">오늘</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vac-cal">
|
||||||
|
<div class="vac-wd-row">
|
||||||
|
{% for wd in weekdays %}
|
||||||
|
<div class="vac-wd {% if loop.index0 == 0 %}vac-red{% elif loop.index0 == 6 %}vac-blue{% endif %}">{{ wd }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for week in weeks %}
|
||||||
|
<div class="vac-week">
|
||||||
|
<div class="vac-week-days">
|
||||||
|
{% for cell in week.days %}
|
||||||
|
<a class="vac-day
|
||||||
|
{% if not cell.in_month %}vac-out{% endif %}
|
||||||
|
{% if cell.is_today %}vac-today{% endif %}
|
||||||
|
{% if cell.is_selected %}vac-selected{% endif %}"
|
||||||
|
href="/vacation/?year={{ year }}&month={{ month }}&date={{ cell.date }}"
|
||||||
|
{% if cell.holiday_name %}title="{{ cell.holiday_name }}"{% endif %}>
|
||||||
|
<span class="vac-day-num
|
||||||
|
{% if cell.is_sunday or cell.is_holiday %}vac-red{% elif cell.is_saturday %}vac-blue{% endif %}">{{ cell.day }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vac-week-bars">
|
||||||
|
{% for bar in week.bars %}
|
||||||
|
<a class="vac-bar {{ bar.status_class }}
|
||||||
|
{% if bar.continues_left %}vac-bar-l{% endif %}
|
||||||
|
{% if bar.continues_right %}vac-bar-r{% endif %}"
|
||||||
|
style="grid-column: {{ bar.start_col + 1 }} / span {{ bar.span }}; grid-row: {{ bar.lane + 1 }};"
|
||||||
|
href="/vacation/{{ bar.id }}"
|
||||||
|
title="{{ bar.label }} ({{ bar.days }}일 · {{ bar.status }})">
|
||||||
|
<span class="vac-bar-label">{{ bar.label }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vac-legend">
|
||||||
|
<span class="vac-leg"><i class="vac-dot vac-bar-submit"></i>제출</span>
|
||||||
|
<span class="vac-leg"><i class="vac-dot vac-bar-approve"></i>승인</span>
|
||||||
|
<span class="vac-leg"><i class="vac-dot vac-bar-reject"></i>반려</span>
|
||||||
|
<span class="vac-leg"><i class="vac-dot vac-bar-cancel"></i>취소</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 오른쪽: 선택일 휴가 리스트 ── -->
|
||||||
|
<div class="erp-card vac-list-card">
|
||||||
|
<div class="vac-list-head">
|
||||||
|
<h2>{{ selected_date }}</h2>
|
||||||
|
<span class="erp-muted">{{ sel_requests|length }}건</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if sel_requests %}
|
||||||
|
<ul class="vac-list">
|
||||||
|
{% for r in sel_requests %}
|
||||||
|
<li class="vac-list-item">
|
||||||
|
<a href="/vacation/{{ r.id }}" class="vac-list-link">
|
||||||
|
<div class="vac-list-top">
|
||||||
|
<strong>{{ r.owner_name or r.owner }}</strong>
|
||||||
|
{% set badge = 'erp-badge-neutral' %}
|
||||||
|
{% if r.status == '승인' %}{% set badge = 'erp-badge-success' %}
|
||||||
|
{% elif r.status == '제출' %}{% set badge = 'erp-badge-inverse' %}
|
||||||
|
{% elif r.status == '반려' %}{% set badge = 'erp-badge-danger' %}{% endif %}
|
||||||
|
<span class="erp-badge {{ badge }}">{{ r.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="vac-list-meta erp-muted">
|
||||||
|
{{ r.vacation_type }} · {{ r.days }}일 ·
|
||||||
|
{{ r.start_date }}{% if r.end_date != r.start_date %} ~ {{ r.end_date }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if r.reason %}<div class="vac-list-reason erp-muted">{{ r.reason }}</div>{% endif %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="erp-muted vac-empty">선택한 날짜의 휴가가 없습니다.
|
||||||
|
<a href="/vacation/new">휴가 신청</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="vac">
|
||||||
|
<div class="erp-card">
|
||||||
|
{% if items %}
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table vac-pending-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>신청자</th><th>휴가종류</th><th>기간</th><th>일수</th>
|
||||||
|
<th>사유</th><th class="vac-act-col">동작</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in items %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/vacation/{{ r.id }}">{{ r.owner_name or r.owner }}</a></td>
|
||||||
|
<td>{{ r.vacation_type }}</td>
|
||||||
|
<td>{{ r.start_date }}{% if r.end_date != r.start_date %} ~ {{ r.end_date }}{% endif %}</td>
|
||||||
|
<td>{{ r.days }}</td>
|
||||||
|
<td class="vac-reason-cell erp-muted">{{ r.reason }}</td>
|
||||||
|
<td class="vac-act-col">
|
||||||
|
<div class="vac-pending-acts">
|
||||||
|
<form method="post" action="/vacation/{{ r.id }}/approve" class="vac-inline-form">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary erp-btn-sm">승인</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/vacation/{{ r.id }}/reject" class="vac-reject-form">
|
||||||
|
<input class="erp-input erp-input-sm" type="text" name="reject_reason"
|
||||||
|
placeholder="반려 사유" required />
|
||||||
|
<button type="submit" class="erp-btn erp-btn-danger erp-btn-sm">반려</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="erp-muted">승인 대기 중인 휴가 신청이 없습니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="vac">
|
||||||
|
|
||||||
|
<div class="vac-settings-head">
|
||||||
|
<form method="get" action="/vacation/settings" class="vac-year-form">
|
||||||
|
<label class="erp-field vac-year-field"><span>연도</span>
|
||||||
|
<input class="erp-input" type="number" name="year" value="{{ year }}" min="2020" max="2100" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vac-settings-grid">
|
||||||
|
|
||||||
|
<!-- ── 공휴일 관리 ── -->
|
||||||
|
<div class="erp-card vac-set-card">
|
||||||
|
<div class="vac-card-head"><h2>공휴일 ({{ year }})</h2></div>
|
||||||
|
|
||||||
|
<form method="post" action="/vacation/settings/holidays" class="vac-holiday-form">
|
||||||
|
<label class="erp-field"><span>날짜 *</span>
|
||||||
|
<input class="erp-input" type="date" name="holiday_date" required value="{{ year }}-01-01" />
|
||||||
|
</label>
|
||||||
|
<label class="erp-field"><span>이름 *</span>
|
||||||
|
<input class="erp-input" type="text" name="name" required placeholder="예: 신정" />
|
||||||
|
</label>
|
||||||
|
<label class="erp-field"><span>종류</span>
|
||||||
|
<select class="erp-select" name="kind">
|
||||||
|
<option value="public">공휴일</option>
|
||||||
|
<option value="lunar">음력</option>
|
||||||
|
<option value="substitute">대체</option>
|
||||||
|
<option value="company">회사지정</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="erp-check"><input type="checkbox" name="is_red" checked /> 빨강(달력 표시)</label>
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary">추가 / 수정</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="erp-table-wrap vac-set-scroll">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead><tr><th>날짜</th><th>이름</th><th>종류</th><th>빨강</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for h in holidays %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ h.holiday_date }}</td>
|
||||||
|
<td>{{ h.name }}</td>
|
||||||
|
<td class="erp-muted">{{ h.kind }}</td>
|
||||||
|
<td>{% if h.is_red %}●{% else %}○{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/vacation/settings/holidays/{{ h.id }}/delete"
|
||||||
|
onsubmit="return confirm('{{ h.holiday_date }} {{ h.name }} 삭제?');">
|
||||||
|
<input type="hidden" name="year" value="{{ year }}" />
|
||||||
|
<button type="submit" class="erp-btn erp-btn-danger erp-btn-sm">삭제</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="erp-muted">등록된 공휴일이 없습니다.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 연차 잔여 관리 ── -->
|
||||||
|
<div class="erp-card vac-set-card">
|
||||||
|
<div class="vac-card-head"><h2>연차 잔여 ({{ year }})</h2></div>
|
||||||
|
|
||||||
|
<form method="post" action="/vacation/settings/balances" class="vac-balance-form">
|
||||||
|
<input type="hidden" name="year" value="{{ year }}" />
|
||||||
|
<label class="erp-field"><span>이메일 *</span>
|
||||||
|
<input class="erp-input" type="email" name="user_email" required
|
||||||
|
placeholder="user@dbxcorp.co.kr" />
|
||||||
|
</label>
|
||||||
|
<label class="erp-field"><span>연차 일수 *</span>
|
||||||
|
<input class="erp-input" type="number" name="total_days" step="0.5" min="0" required value="15" />
|
||||||
|
</label>
|
||||||
|
<label class="erp-field vac-full"><span>메모</span>
|
||||||
|
<input class="erp-input" type="text" name="memo" placeholder="입사일/비고 등" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary">설정</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="erp-table-wrap vac-set-scroll">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead><tr><th>이메일</th><th>연차</th><th>사용</th><th>메모</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in balances %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ b.user_email }}</td>
|
||||||
|
<td>{{ b.total_days }}</td>
|
||||||
|
<td class="erp-muted">{{ b.used_days }}</td>
|
||||||
|
<td class="erp-muted">{{ b.memo }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4" class="erp-muted">설정된 연차가 없습니다.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="erp-muted vac-set-note">사용 일수는 승인된 휴가 합계로 자동 계산됩니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,307 @@
|
|||||||
|
/* 쿠팡 밀크런 — DESIGN.md 토큰 준수 (흰 배경 / 검정·회색 / 10·14px radius / 카드 16px) */
|
||||||
|
|
||||||
|
.cpg { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.cpg-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||||
|
/* 맨 오른쪽으로 밀기 (수정 버튼) */
|
||||||
|
.cpg-push-right { margin-left: auto; }
|
||||||
|
/* 달력 상단 설정 버튼 그룹 — 우측 정렬 */
|
||||||
|
.cpg-settings-btns { margin-left: auto; display: inline-flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
/* 액션바를 달력 레이아웃(좌:달력 1fr / 우:리스트 320)과 같은 그리드로
|
||||||
|
→ 설정 버튼이 달력 컬럼 오른쪽 끝에 정렬 */
|
||||||
|
.cpg-actions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.cpg-actions-main { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.cpg-actions-grid { grid-template-columns: 1fr; }
|
||||||
|
.cpg-actions-spacer { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 레이아웃: 왼쪽 큰 달력 + 오른쪽 리스트 ── */
|
||||||
|
.cpg-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: 1 1 auto; min-height: 0; /* 남은 높이 채움 → 문서 스크롤 방지 */
|
||||||
|
}
|
||||||
|
@media (max-width: 980px) { .cpg-layout { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.cpg-cal-card, .cpg-list-card { padding: 16px; }
|
||||||
|
.cpg-cal-card { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||||
|
.cpg-list-card { min-height: 0; overflow-y: auto; }
|
||||||
|
|
||||||
|
.cpg-cal-head {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin-bottom: 12px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cpg-cal-title { font-size: 18pt; font-weight: 600; letter-spacing: -0.45px; margin: 0; }
|
||||||
|
.cpg-nav-btn { min-width: 36px; padding: 4px 10px; font-size: 18pt; line-height: 1; }
|
||||||
|
|
||||||
|
.cpg-cal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-template-rows: auto repeat(6, 1fr); /* 요일행 auto + 6주 균등 분할 */
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1 1 auto; min-height: 0;
|
||||||
|
}
|
||||||
|
.cpg-cal-wd {
|
||||||
|
text-align: center; font-size: 15px; font-weight: 600;
|
||||||
|
color: var(--color-midtone-gray); padding: 6px 0;
|
||||||
|
}
|
||||||
|
.cpg-sun { color: var(--color-callout-red); }
|
||||||
|
.cpg-sat { color: #2b5bc2; }
|
||||||
|
|
||||||
|
.cpg-cal-cell {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
min-height: 0; padding: 8px; overflow: hidden;
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
transition: border-color .12s, box-shadow .12s;
|
||||||
|
}
|
||||||
|
.cpg-cal-cell:hover { border-color: var(--color-midtone-gray); }
|
||||||
|
.cpg-out { background: var(--color-ghost-gray); }
|
||||||
|
.cpg-out .cpg-cal-day { color: var(--color-midtone-gray); }
|
||||||
|
.cpg-today { border-color: var(--color-deep-black); }
|
||||||
|
.cpg-selected { box-shadow: 0 0 0 2px var(--color-deep-black); border-color: var(--color-deep-black); }
|
||||||
|
.cpg-cal-day { font-size: 17px; font-weight: 600; }
|
||||||
|
/* 일요일/공휴일 빨강, 토요일 파랑 (당월 셀 우선, 전후월은 옅게) */
|
||||||
|
.cpg-red .cpg-cal-day { color: var(--color-callout-red); }
|
||||||
|
.cpg-blue .cpg-cal-day { color: #2b5bc2; }
|
||||||
|
.cpg-out.cpg-red .cpg-cal-day { color: #e3a59b; }
|
||||||
|
.cpg-out.cpg-blue .cpg-cal-day { color: #9db4dd; }
|
||||||
|
.cpg-cal-badges { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.cpg-mini { font-size: 12px; padding: 2px 7px; align-self: flex-start; }
|
||||||
|
|
||||||
|
/* ── 오른쪽 리스트 ── */
|
||||||
|
.cpg-list-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }
|
||||||
|
.cpg-list-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||||
|
.cpg-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.cpg-list-item { border: 1px solid var(--color-subtle-ash); border-radius: 10px; }
|
||||||
|
.cpg-list-link { display: block; padding: 10px 12px; }
|
||||||
|
.cpg-list-link:hover { background: var(--color-ghost-gray); border-radius: 10px; }
|
||||||
|
.cpg-list-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.cpg-list-meta { font-size: 12px; margin-top: 4px; }
|
||||||
|
.cpg-empty { padding: 16px 0; }
|
||||||
|
|
||||||
|
/* 선택일 출고 hover 툴팁 (마우스 따라다님) */
|
||||||
|
.cpg-hover-tip {
|
||||||
|
position: fixed; z-index: 60; pointer-events: none;
|
||||||
|
background: var(--color-deep-black); color: #fff;
|
||||||
|
border-radius: 10px; padding: 8px 10px;
|
||||||
|
min-width: 160px; max-width: 280px;
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,.22);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.cpg-tip-row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
|
||||||
|
.cpg-tip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.cpg-tip-qty { font-weight: 600; flex: 0 0 auto; }
|
||||||
|
.cpg-tip-empty { color: #d4d4d4; }
|
||||||
|
|
||||||
|
/* ── 폼 ── */
|
||||||
|
.cpg-form-card { padding: 16px; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
/* 공통 헤더(좌) + 품목 라인(우) 2열 */
|
||||||
|
/* form 이 .cpg(남은 높이) 안에서 세로로 채우도록 */
|
||||||
|
.cpg > form { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
|
||||||
|
.cpg-form-2col {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 16px; align-items: stretch;
|
||||||
|
flex: 1 1 auto; min-height: 0;
|
||||||
|
}
|
||||||
|
/* 공통 헤더: 너비 450 고정 */
|
||||||
|
.cpg-form-head { flex: 0 0 450px; width: 450px; min-width: 0; margin-bottom: 0; align-self: flex-start; }
|
||||||
|
/* 품목 라인: 너비 900 고정, 높이는 가용 영역 채움, 내부 스크롤 */
|
||||||
|
.cpg-form-lines {
|
||||||
|
flex: 0 0 900px; width: 900px; height: 100%; min-width: 0; margin-bottom: 0;
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.cpg-form-lines { flex-basis: auto; width: 100%; }
|
||||||
|
}
|
||||||
|
@media (max-width: 940px) {
|
||||||
|
.cpg-form-head { flex-basis: 100%; width: 100%; }
|
||||||
|
}
|
||||||
|
/* 라인 테이블 영역만 스크롤 */
|
||||||
|
.cpg-lines-scroll { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
||||||
|
/* 제목행: 제목 + 설명 + (우측) 추가/삭제 버튼 */
|
||||||
|
.cpg-lines-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.cpg-lines-btns { margin-left: auto; display: flex; gap: 8px; flex: 0 0 auto; }
|
||||||
|
|
||||||
|
/* 저장/취소 — 공통 헤더 카드 하단에 위치(우측 라인 수와 무관) */
|
||||||
|
.cpg-form-actions { margin-top: 16px; }
|
||||||
|
.cpg-card-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
|
.cpg-card-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||||
|
|
||||||
|
.cpg-header-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, 185px);
|
||||||
|
gap: 12px 16px;
|
||||||
|
}
|
||||||
|
/* 작성일·출고일·센터입고일·입고센터·출고방식·작업자 입력 185px 고정
|
||||||
|
(.cpg .erp-field > .erp-input width:100% 보다 특이도 높게) */
|
||||||
|
.cpg .cpg-header-grid .erp-field > .erp-input,
|
||||||
|
.cpg .cpg-header-grid .erp-field > .erp-select {
|
||||||
|
width: 185px; max-width: 185px; min-width: 185px; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.cpg-rule-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px 16px; align-items: end;
|
||||||
|
}
|
||||||
|
/* 그리드 항목 겹침 방지: 칸이 줄어들 때 내용이 밖으로 넘치지 않게 */
|
||||||
|
.cpg-header-grid .erp-field,
|
||||||
|
.cpg-rule-grid .erp-field { min-width: 0; }
|
||||||
|
.cpg .erp-field { display: flex; flex-direction: column; gap: 4px; margin: 0; }
|
||||||
|
.cpg .erp-field > span { font-size: 12px; color: var(--color-midtone-gray); }
|
||||||
|
/* 입력란이 칸 너비를 넘지 않도록 (date/select 포함) */
|
||||||
|
.cpg .erp-field > .erp-input,
|
||||||
|
.cpg .erp-field > .erp-select,
|
||||||
|
.cpg .erp-field > textarea.erp-input { width: 100%; max-width: 100%; box-sizing: border-box; }
|
||||||
|
.cpg-full { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
/* 검정 배경 버튼 글자는 항상 흰색 (안전 보강) */
|
||||||
|
.cpg .erp-btn-primary, .cpg .erp-btn-primary:visited { color: var(--color-canvas-white); }
|
||||||
|
|
||||||
|
.cpg-inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; }
|
||||||
|
.cpg-row-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ── 입고센터 관리 (좌: 추가 / 우: 목록) ── */
|
||||||
|
.cpg-center-layout {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start;
|
||||||
|
}
|
||||||
|
.cpg-center-add {
|
||||||
|
width: 360px; flex: 0 0 auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.cpg-center-listcard {
|
||||||
|
flex: 1 1 480px; min-width: 0;
|
||||||
|
height: 900px;
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.cpg-center-add, .cpg-center-listcard { width: 100%; flex-basis: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpg-center-add-title { font-size: 15px; font-weight: 600; margin: 0 0 8px; }
|
||||||
|
.cpg-btn-sm { padding: 3px 8px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* 목록: 카드 높이 채우고 내부 스크롤 */
|
||||||
|
.cpg-center-list {
|
||||||
|
flex: 1 1 auto; min-height: 0; overflow-y: auto;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpg-center-row {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid var(--color-subtle-ash); border-radius: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.cpg-center-row.is-inactive { opacity: .55; }
|
||||||
|
.cpg-center-edit { display: flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
|
||||||
|
.cpg-center-name { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.cpg-center-sort { width: 56px; flex: 0 0 auto; }
|
||||||
|
.cpg-center-state { display: inline-flex; gap: 4px; flex: 0 0 auto; }
|
||||||
|
.cpg-center-act { display: inline-flex; gap: 4px; flex: 0 0 auto; }
|
||||||
|
|
||||||
|
/* ── 라인 테이블 ── */
|
||||||
|
.cpg-lines td { vertical-align: middle; }
|
||||||
|
.cpg-lines th.cpg-check-col,
|
||||||
|
.cpg-lines td.cpg-check-col {
|
||||||
|
width: 36px; text-align: center;
|
||||||
|
padding-left: 0; padding-right: 0; vertical-align: middle;
|
||||||
|
}
|
||||||
|
.cpg-check-col input { display: block; margin: 0 auto; cursor: pointer; }
|
||||||
|
.cpg-lines .cpg-name-sel { width: 100%; min-width: 160px; }
|
||||||
|
/* 폭 고정: 제품코드 140px, 수량·입수량 60px */
|
||||||
|
.cpg-lines .cpg-code { width: 140px; min-width: 140px; box-sizing: border-box; }
|
||||||
|
.cpg-lines .cpg-qty,
|
||||||
|
.cpg-lines .cpg-upb { width: 60px; min-width: 60px; box-sizing: border-box; }
|
||||||
|
.cpg-lines .cpg-memo { width: 100%; min-width: 120px; box-sizing: border-box; }
|
||||||
|
.cpg-line-calc { font-size: 13px; color: var(--color-midtone-gray); white-space: nowrap; }
|
||||||
|
.cpg-line-calc.cpg-warn { color: var(--color-callout-red); }
|
||||||
|
.cpg-line-del { cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── 상세 ── */
|
||||||
|
.cpg-detail-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px; margin: 0;
|
||||||
|
}
|
||||||
|
.cpg-detail-grid dt { font-size: 12px; color: var(--color-midtone-gray); }
|
||||||
|
.cpg-detail-grid dd { margin: 2px 0 0; font-size: 14px; }
|
||||||
|
|
||||||
|
/* ── 설정(제품명) 2열 레이아웃 ── */
|
||||||
|
.cpg-prod-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px; align-items: flex-start;
|
||||||
|
}
|
||||||
|
/* 미라네 주방 상품: 폭 500 / 높이 900 고정, 내부 스크롤 */
|
||||||
|
.cpg-prod-left {
|
||||||
|
width: 500px; height: 900px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* 등록된 제품명: 폭 600 / 높이 900 고정, 내부 스크롤 */
|
||||||
|
.cpg-prod-right {
|
||||||
|
width: 600px; height: 900px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.cpg-prod-left, .cpg-prod-right { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpg-src-list {
|
||||||
|
flex: 1 1 auto; min-height: 0; overflow-y: auto;
|
||||||
|
border: 1px solid var(--color-subtle-ash); border-radius: 10px;
|
||||||
|
}
|
||||||
|
/* 등록 목록 테이블 스크롤 영역 (카드 높이에서 헤더 제외하고 채움) */
|
||||||
|
.cpg-reg-scroll {
|
||||||
|
flex: 1 1 auto; min-height: 0; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.cpg-src-item {
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
|
||||||
|
padding: 8px 12px; cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--color-subtle-ash);
|
||||||
|
}
|
||||||
|
.cpg-src-item:last-child { border-bottom: 0; }
|
||||||
|
.cpg-src-item:hover { background: var(--color-ghost-gray); }
|
||||||
|
.cpg-src-name { font-size: 14px; font-weight: 500; }
|
||||||
|
.cpg-src-code { font-size: 12px; color: var(--color-midtone-gray); white-space: nowrap; }
|
||||||
|
/* 선택됨: 검정 테두리 강조 */
|
||||||
|
.cpg-src-item.is-selected { box-shadow: inset 0 0 0 2px var(--color-deep-black); }
|
||||||
|
/* 이미 등록됨: 진한 회색 배경 + 흰 글씨 */
|
||||||
|
.cpg-src-item.cpg-registered { background: #4b4b4b; }
|
||||||
|
.cpg-src-item.cpg-registered .cpg-src-name { color: #fff; }
|
||||||
|
.cpg-src-item.cpg-registered .cpg-src-code { color: #d4d4d4; }
|
||||||
|
.cpg-src-item.cpg-registered:hover { background: #3a3a3a; }
|
||||||
|
|
||||||
|
/* ── 박스 입수량 (좌: 추가/수정 480 / 우: 목록 900) ── */
|
||||||
|
.cpg-brule-layout { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; }
|
||||||
|
.cpg-brule-add { flex: 0 0 480px; width: 480px; min-width: 0; margin-bottom: 0; }
|
||||||
|
.cpg-brule-list { flex: 0 0 900px; width: 900px; min-width: 0; margin-bottom: 0; }
|
||||||
|
@media (max-width: 1420px) { .cpg-brule-list { flex-basis: auto; width: 100%; } }
|
||||||
|
@media (max-width: 540px) { .cpg-brule-add { flex-basis: 100%; width: 100%; } }
|
||||||
|
|
||||||
|
.cpg-brule-fields { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.cpg-brule-row { display: flex; gap: 16px; align-items: flex-end; }
|
||||||
|
.cpg-brule-memo-field { flex: 1 1 auto; min-width: 0; }
|
||||||
|
/* 필드별 고정 폭 (erp.css min-width:240 오버라이드) */
|
||||||
|
.cpg .cpg-brule-fields .cpg-brule-name { width: 180px; min-width: 180px; max-width: 180px; box-sizing: border-box; }
|
||||||
|
.cpg .cpg-brule-fields .cpg-brule-code { width: 100px; min-width: 100px; max-width: 100px; box-sizing: border-box; }
|
||||||
|
/* 박스이름 폭을 제품명(180)과 동일하게 → 박스당 입수량이 제품코드와 세로 정렬 */
|
||||||
|
.cpg .cpg-brule-fields .cpg-brule-box { width: 180px; min-width: 180px; max-width: 180px; box-sizing: border-box; }
|
||||||
|
/* 박스당 입수량 60px + "개" */
|
||||||
|
.cpg-upb-wrap { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.cpg .cpg-brule-fields .cpg-upb-wrap > .cpg-brule-upb {
|
||||||
|
width: 60px; min-width: 60px; max-width: 60px; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.cpg-upb-unit { font-size: 13px; color: var(--color-midtone-gray); }
|
||||||
|
/* 메모: 프레임 전체 너비 */
|
||||||
|
.cpg .cpg-brule-fields .cpg-brule-memo { width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box; }
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
/* 쿠팡 밀크런 — 출고 폼 동적 라인.
|
||||||
|
제품명 드롭다운(설정에서 등록한 카탈로그) 선택 → 제품코드 자동 입력.
|
||||||
|
수량 입력 시 박스 수 미리보기(서버가 저장 시 store.compute_boxes 로 재계산). */
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
var body = document.getElementById("cpg-lines-body");
|
||||||
|
var form = document.getElementById("cpg-form");
|
||||||
|
if (!body || !form) return;
|
||||||
|
|
||||||
|
// 입수량 규칙: product_code -> units_per_box
|
||||||
|
var ruleMap = {};
|
||||||
|
try {
|
||||||
|
var rules = JSON.parse(document.getElementById("cpg-box-rules").textContent || "[]");
|
||||||
|
rules.forEach(function (r) { if (r.active !== false) ruleMap[r.product_code] = r.units_per_box; });
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// 제품 카탈로그: [{product_code, product_name}]
|
||||||
|
var products = [];
|
||||||
|
try { products = JSON.parse(document.getElementById("cpg-products").textContent || "[]"); } catch (e) {}
|
||||||
|
var nameByCode = {};
|
||||||
|
products.forEach(function (p) { nameByCode[p.product_code] = p.product_name; });
|
||||||
|
|
||||||
|
var initLines = [];
|
||||||
|
try { initLines = JSON.parse(document.getElementById("cpg-init-lines").textContent || "[]"); } catch (e) {}
|
||||||
|
|
||||||
|
function ceilDiv(a, b) { return Math.ceil(a / b); }
|
||||||
|
|
||||||
|
function recalc(row) {
|
||||||
|
var qty = parseInt(row.querySelector(".cpg-qty").value, 10) || 0;
|
||||||
|
var upb = parseInt(row.querySelector(".cpg-upb").value, 10);
|
||||||
|
var cell = row.querySelector(".cpg-line-calc");
|
||||||
|
if (!upb || upb <= 0) { cell.textContent = "미설정"; cell.classList.add("cpg-warn"); return; }
|
||||||
|
cell.classList.remove("cpg-warn");
|
||||||
|
if (qty <= 0) { cell.textContent = "—"; return; }
|
||||||
|
var boxes = ceilDiv(qty, upb), rem = qty % upb;
|
||||||
|
cell.textContent = boxes + "박스" + (rem ? " +" + rem : " (딱맞음)");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNameSelect(selectedCode) {
|
||||||
|
var sel = document.createElement("select");
|
||||||
|
sel.className = "erp-select cpg-name-sel";
|
||||||
|
var opt0 = document.createElement("option");
|
||||||
|
opt0.value = ""; opt0.textContent = "— 제품명 선택 —";
|
||||||
|
sel.appendChild(opt0);
|
||||||
|
var found = false;
|
||||||
|
products.forEach(function (p) {
|
||||||
|
var o = document.createElement("option");
|
||||||
|
o.value = p.product_code;
|
||||||
|
o.textContent = p.product_name;
|
||||||
|
o.setAttribute("data-name", p.product_name);
|
||||||
|
if (p.product_code === selectedCode) { o.selected = true; found = true; }
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
// 카탈로그에 없는 기존 라인 코드 → 임시 옵션으로 표시
|
||||||
|
if (selectedCode && !found) {
|
||||||
|
var o = document.createElement("option");
|
||||||
|
o.value = selectedCode;
|
||||||
|
o.textContent = (nameByCode[selectedCode] || selectedCode) + " (미등록)";
|
||||||
|
o.setAttribute("data-name", nameByCode[selectedCode] || selectedCode);
|
||||||
|
o.selected = true;
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
return sel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRow(data) {
|
||||||
|
data = data || {};
|
||||||
|
var tr = document.createElement("tr");
|
||||||
|
// 컬럼: 체크 / 제품명(select) / 제품코드 / 수량 / 입수량 / 박스계산 / 라인메모
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td class="cpg-check-col"><input type="checkbox" class="cpg-row-check" /></td>' +
|
||||||
|
'<td class="cpg-cell-name"></td>' +
|
||||||
|
'<td><input class="erp-input cpg-code" type="text" placeholder="제품코드" /></td>' +
|
||||||
|
'<td><input class="erp-input cpg-qty" type="number" min="1" /></td>' +
|
||||||
|
'<td><input class="erp-input cpg-upb" type="number" min="1" placeholder="입수량" /></td>' +
|
||||||
|
'<td><span class="cpg-line-calc">—</span></td>' +
|
||||||
|
'<td><input class="erp-input cpg-memo" type="text" /></td>';
|
||||||
|
|
||||||
|
var code = data.product_code || "";
|
||||||
|
var nameSel = buildNameSelect(code);
|
||||||
|
tr.querySelector(".cpg-cell-name").appendChild(nameSel);
|
||||||
|
|
||||||
|
var codeInput = tr.querySelector(".cpg-code");
|
||||||
|
codeInput.value = code;
|
||||||
|
tr.querySelector(".cpg-qty").value = data.quantity || "";
|
||||||
|
var upb = data.units_per_box;
|
||||||
|
if (upb == null && code && ruleMap[code] != null) upb = ruleMap[code];
|
||||||
|
tr.querySelector(".cpg-upb").value = (upb != null ? upb : "");
|
||||||
|
|
||||||
|
// 제품명 선택 → 코드 자동 입력 + 입수량 자동
|
||||||
|
nameSel.addEventListener("change", function () {
|
||||||
|
var c = nameSel.value;
|
||||||
|
codeInput.value = c;
|
||||||
|
if (c && ruleMap[c] != null) tr.querySelector(".cpg-upb").value = ruleMap[c];
|
||||||
|
recalc(tr);
|
||||||
|
});
|
||||||
|
// 코드 직접 입력 시 입수량 규칙 자동
|
||||||
|
codeInput.addEventListener("input", function () {
|
||||||
|
var c = codeInput.value.trim();
|
||||||
|
if (c && ruleMap[c] != null && !tr.querySelector(".cpg-upb").value) {
|
||||||
|
tr.querySelector(".cpg-upb").value = ruleMap[c];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tr.querySelector(".cpg-qty").addEventListener("input", function () { recalc(tr); });
|
||||||
|
tr.querySelector(".cpg-upb").addEventListener("input", function () { recalc(tr); });
|
||||||
|
|
||||||
|
body.appendChild(tr);
|
||||||
|
recalc(tr);
|
||||||
|
syncCheckAll();
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 선택 체크박스 상태 동기화
|
||||||
|
var checkAll = document.getElementById("cpg-check-all");
|
||||||
|
function syncCheckAll() {
|
||||||
|
if (!checkAll) return;
|
||||||
|
var checks = body.querySelectorAll(".cpg-row-check");
|
||||||
|
var total = checks.length;
|
||||||
|
var on = 0;
|
||||||
|
Array.prototype.forEach.call(checks, function (c) { if (c.checked) on++; });
|
||||||
|
checkAll.checked = total > 0 && on === total;
|
||||||
|
checkAll.indeterminate = on > 0 && on < total;
|
||||||
|
}
|
||||||
|
if (checkAll) {
|
||||||
|
checkAll.addEventListener("change", function () {
|
||||||
|
Array.prototype.forEach.call(body.querySelectorAll(".cpg-row-check"), function (c) {
|
||||||
|
c.checked = checkAll.checked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
body.addEventListener("change", function (e) {
|
||||||
|
if (e.target && e.target.classList.contains("cpg-row-check")) syncCheckAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 초기 라인
|
||||||
|
if (initLines.length) { initLines.forEach(makeRow); } else { makeRow(); }
|
||||||
|
document.getElementById("cpg-add-line").addEventListener("click", function () { makeRow(); });
|
||||||
|
|
||||||
|
// 선택 라인 삭제 (체크된 행 삭제, 최소 1줄 유지)
|
||||||
|
var delBtn = document.getElementById("cpg-del-line");
|
||||||
|
if (delBtn) {
|
||||||
|
delBtn.addEventListener("click", function () {
|
||||||
|
var checked = body.querySelectorAll(".cpg-row-check:checked");
|
||||||
|
if (!checked.length) { alert("삭제할 라인을 선택하세요."); return; }
|
||||||
|
Array.prototype.forEach.call(checked, function (c) {
|
||||||
|
var tr = c.closest("tr");
|
||||||
|
if (tr) tr.remove();
|
||||||
|
});
|
||||||
|
if (!body.querySelectorAll("tr").length) makeRow(); // 최소 1줄
|
||||||
|
if (checkAll) { checkAll.checked = false; checkAll.indeterminate = false; }
|
||||||
|
syncCheckAll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 제출: 라인 직렬화
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
var lines = [];
|
||||||
|
Array.prototype.forEach.call(body.querySelectorAll("tr"), function (tr) {
|
||||||
|
var code = tr.querySelector(".cpg-code").value.trim();
|
||||||
|
var qty = parseInt(tr.querySelector(".cpg-qty").value, 10) || 0;
|
||||||
|
if (!code || qty <= 0) return;
|
||||||
|
var upb = parseInt(tr.querySelector(".cpg-upb").value, 10);
|
||||||
|
var sel = tr.querySelector(".cpg-name-sel");
|
||||||
|
var name = "";
|
||||||
|
if (sel && sel.selectedIndex >= 0) {
|
||||||
|
var opt = sel.options[sel.selectedIndex];
|
||||||
|
name = opt ? (opt.getAttribute("data-name") || "") : "";
|
||||||
|
}
|
||||||
|
if (!name) name = nameByCode[code] || code;
|
||||||
|
lines.push({
|
||||||
|
product_code: code,
|
||||||
|
product_name_snapshot: name,
|
||||||
|
quantity: qty,
|
||||||
|
units_per_box: (upb > 0 ? upb : null),
|
||||||
|
memo: tr.querySelector(".cpg-memo").value.trim()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!lines.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert("품목 라인을 최소 1개 입력하세요 (제품명/코드 + 수량).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById("cpg-lines-json").value = JSON.stringify(lines);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 입고센터 select → 스냅샷 hidden 동기화
|
||||||
|
var centerSel = document.getElementById("cpg-center-select");
|
||||||
|
var centerName = document.getElementById("cpg-center-name");
|
||||||
|
if (centerSel && centerName) {
|
||||||
|
function syncCenter() {
|
||||||
|
var opt = centerSel.options[centerSel.selectedIndex];
|
||||||
|
if (opt && opt.value) centerName.value = opt.getAttribute("data-name") || opt.text;
|
||||||
|
}
|
||||||
|
centerSel.addEventListener("change", syncCenter);
|
||||||
|
if (!centerName.value) syncCenter();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -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&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 |
|
After Width: | Height: | Size: 67 KiB |
@@ -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 |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,129 @@
|
|||||||
|
/* ════════════════════════════════════════════════════
|
||||||
|
첨부 뷰어 모달 — 이미지 패닝 + 엑셀 시트
|
||||||
|
════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.eav-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0, 0, 0, 0.62);
|
||||||
|
display: none;
|
||||||
|
align-items: stretch; justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.eav-overlay[data-open="true"] { display: flex; }
|
||||||
|
|
||||||
|
.eav-modal {
|
||||||
|
background: var(--color-canvas-white, #fff);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1280px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eav-head {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
}
|
||||||
|
.eav-title { font-weight: 600; font-size: 14px; flex: 1; min-width: 0;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.eav-toolbar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.eav-btn {
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
background: var(--color-canvas-white, #fff);
|
||||||
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
|
padding: 5px 10px; font-size: 12px; border-radius: 8px; cursor: pointer;
|
||||||
|
font-family: inherit; text-decoration: none; display: inline-block;
|
||||||
|
}
|
||||||
|
.eav-btn:hover { background: var(--color-ghost-gray, #f2f2f2); }
|
||||||
|
.eav-btn.is-active { background: var(--color-deep-black, #000); color: #fff; border-color: #000; }
|
||||||
|
.eav-close {
|
||||||
|
width: 28px; height: 28px; border-radius: 8px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border: 0; background: transparent; cursor: pointer; font-size: 18px;
|
||||||
|
}
|
||||||
|
.eav-close:hover { background: var(--color-ghost-gray, #f2f2f2); }
|
||||||
|
|
||||||
|
.eav-body {
|
||||||
|
display: grid; grid-template-columns: 220px 1fr; min-height: 0; height: 100%;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
.eav-list {
|
||||||
|
background: #fff; border-right: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.eav-item {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 8px; border-radius: 8px; cursor: pointer;
|
||||||
|
border: 1px solid transparent; background: transparent;
|
||||||
|
text-align: left; font-family: inherit; font-size: 12px;
|
||||||
|
}
|
||||||
|
.eav-item:hover { background: var(--color-ghost-gray, #f2f2f2); }
|
||||||
|
.eav-item.is-active { background: var(--color-ghost-gray, #f2f2f2); border-color: var(--color-subtle-ash, #e5e5e5); }
|
||||||
|
.eav-item-thumb {
|
||||||
|
width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0;
|
||||||
|
background: var(--color-ghost-gray, #f2f2f2);
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 18px; color: var(--color-midtone-gray, #737373);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.eav-item-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.eav-item-meta { flex: 1; min-width: 0; }
|
||||||
|
.eav-item-name { font-weight: 500; color: var(--color-rich-black, #0a0a0a);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
|
||||||
|
.eav-item-sub { font-size: 11px; color: var(--color-midtone-gray, #737373); display: block; }
|
||||||
|
|
||||||
|
/* 이미지 패닝 스테이지 */
|
||||||
|
.eav-canvas-wrap {
|
||||||
|
position: relative;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0;
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
.eav-canvas-wrap.eav-pan { cursor: grab; }
|
||||||
|
.eav-canvas-wrap.eav-pan.is-panning { cursor: grabbing; }
|
||||||
|
.eav-img { display: block; user-select: none; -webkit-user-drag: none; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* 엑셀/시트 */
|
||||||
|
.eav-sheet-wrap {
|
||||||
|
background: #fff;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
display: grid; grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
.eav-sheet-tabs {
|
||||||
|
display: flex; gap: 4px; padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
background: var(--color-ghost-gray, #f2f2f2);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.eav-sheet-body {
|
||||||
|
overflow: auto; padding: 12px;
|
||||||
|
}
|
||||||
|
.eav-sheet-body table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: 'Geist Mono', Menlo, monospace; font-size: 12px;
|
||||||
|
}
|
||||||
|
.eav-sheet-body td, .eav-sheet-body th {
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
padding: 4px 8px;
|
||||||
|
min-width: 60px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.eav-sheet-body th { background: var(--color-ghost-gray, #f2f2f2); font-weight: 600; }
|
||||||
|
.eav-sheet-loading { color: #fff; text-align: center; padding: 40px; }
|
||||||
|
|
||||||
|
.eav-fallback {
|
||||||
|
color: #fff; text-align: center; padding: 40px;
|
||||||
|
}
|
||||||
|
.eav-fallback a {
|
||||||
|
display: inline-block; margin-top: 12px;
|
||||||
|
background: #fff; color: #000; padding: 8px 16px; border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.eav-body { grid-template-columns: 1fr; grid-template-rows: 100px 1fr; }
|
||||||
|
.eav-list { flex-direction: row; overflow-x: auto; }
|
||||||
|
.eav-item { flex-shrink: 0; min-width: 180px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// ErpAttachViewer — 첨부 모달
|
||||||
|
// - 이미지: 마우스 드래그로 패닝 (이미지 크면 스크롤). 확대/축소/맞춤.
|
||||||
|
// - 엑셀 (xlsx/xls/csv): SheetJS 로 시트 표시 (CDN lazy load).
|
||||||
|
// - 기타: 다운로드 링크 표시.
|
||||||
|
// API: ErpAttachViewer.openFor(itemId, { title })
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
if (window.ErpAttachViewer) return;
|
||||||
|
|
||||||
|
const IMG_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
|
||||||
|
const SHEET_EXTS = new Set(["xlsx", "xls", "xlsm", "csv", "ods"]);
|
||||||
|
const XLSX_CDN = "https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js";
|
||||||
|
|
||||||
|
let overlay = null;
|
||||||
|
let state = {
|
||||||
|
items: [],
|
||||||
|
currentIdx: -1,
|
||||||
|
title: "",
|
||||||
|
zoom: 1, // 이미지 줌 (1 = 100%)
|
||||||
|
// 패닝
|
||||||
|
panning: false,
|
||||||
|
panStartX: 0, panStartY: 0,
|
||||||
|
scrollStartLeft: 0, scrollStartTop: 0,
|
||||||
|
};
|
||||||
|
let xlsxLoading = null;
|
||||||
|
|
||||||
|
function ensureOverlay() {
|
||||||
|
if (overlay) return overlay;
|
||||||
|
overlay = document.createElement("div");
|
||||||
|
overlay.className = "eav-overlay";
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="eav-modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="eav-head">
|
||||||
|
<div class="eav-title" data-role="title">첨부 보기</div>
|
||||||
|
<div class="eav-toolbar" data-role="toolbar"></div>
|
||||||
|
<button class="eav-close" data-role="close" aria-label="닫기">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="eav-body">
|
||||||
|
<div class="eav-list" data-role="list"></div>
|
||||||
|
<div class="eav-canvas-wrap" data-role="stage"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||||||
|
overlay.querySelector('[data-role="close"]').addEventListener("click", close);
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (overlay.dataset.open === "true" && e.key === "Escape") close();
|
||||||
|
});
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadXlsxLib() {
|
||||||
|
if (window.XLSX) return Promise.resolve(window.XLSX);
|
||||||
|
if (xlsxLoading) return xlsxLoading;
|
||||||
|
xlsxLoading = new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement("script");
|
||||||
|
s.src = XLSX_CDN;
|
||||||
|
s.onload = () => resolve(window.XLSX);
|
||||||
|
s.onerror = () => reject(new Error("XLSX 라이브러리 로드 실패"));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
return xlsxLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolbar() {
|
||||||
|
const tb = overlay.querySelector('[data-role="toolbar"]');
|
||||||
|
tb.innerHTML = "";
|
||||||
|
const cur = state.items[state.currentIdx];
|
||||||
|
if (!cur) return;
|
||||||
|
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
|
||||||
|
|
||||||
|
if (IMG_EXTS.has(ext)) {
|
||||||
|
[
|
||||||
|
{ label: "-", title: "축소", fn: () => setZoom(state.zoom / 1.25) },
|
||||||
|
{ label: "100%", title: "원본", fn: () => setZoom(1) },
|
||||||
|
{ label: "+", title: "확대", fn: () => setZoom(state.zoom * 1.25) },
|
||||||
|
{ label: "맞춤", title: "화면에 맞춤", fn: () => setZoom("fit") },
|
||||||
|
].forEach((b) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "eav-btn";
|
||||||
|
btn.textContent = b.label;
|
||||||
|
btn.title = b.title;
|
||||||
|
btn.onclick = b.fn;
|
||||||
|
tb.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dl = document.createElement("a");
|
||||||
|
dl.className = "eav-btn";
|
||||||
|
dl.textContent = "다운로드";
|
||||||
|
dl.href = `/expense/api/attachments/${cur.id}`;
|
||||||
|
dl.setAttribute("download", cur.filename);
|
||||||
|
tb.appendChild(dl);
|
||||||
|
|
||||||
|
const open = document.createElement("a");
|
||||||
|
open.className = "eav-btn";
|
||||||
|
open.textContent = "새 창";
|
||||||
|
open.href = `/expense/api/attachments/${cur.id}`;
|
||||||
|
open.target = "_blank";
|
||||||
|
tb.appendChild(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildList() {
|
||||||
|
const list = overlay.querySelector('[data-role="list"]');
|
||||||
|
list.innerHTML = "";
|
||||||
|
state.items.forEach((a, i) => {
|
||||||
|
const item = document.createElement("button");
|
||||||
|
item.className = "eav-item" + (i === state.currentIdx ? " is-active" : "");
|
||||||
|
const ext = (a.filename.split(".").pop() || "").toLowerCase();
|
||||||
|
const isImg = IMG_EXTS.has(ext);
|
||||||
|
const isSheet = SHEET_EXTS.has(ext);
|
||||||
|
const icon = isImg ? `<img src="/expense/api/attachments/${a.id}" alt="" />`
|
||||||
|
: isSheet ? "📊" : "📄";
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="eav-item-thumb">${icon}</span>
|
||||||
|
<span class="eav-item-meta">
|
||||||
|
<span class="eav-item-name">${escapeHtml(a.filename)}</span>
|
||||||
|
<span class="eav-item-sub">${a.kind === "receipt" ? "영수증" : "기타파일"} · ${fmtSize(a.size_bytes)}</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
item.onclick = () => { state.currentIdx = i; state.zoom = 1; renderCurrent(); buildList(); buildToolbar(); };
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurrent() {
|
||||||
|
const stage = overlay.querySelector('[data-role="stage"]');
|
||||||
|
stage.innerHTML = "";
|
||||||
|
stage.className = "eav-canvas-wrap";
|
||||||
|
const cur = state.items[state.currentIdx];
|
||||||
|
if (!cur) {
|
||||||
|
stage.innerHTML = `<div class="eav-fallback">첨부가 없습니다.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
|
||||||
|
|
||||||
|
if (IMG_EXTS.has(ext)) return renderImage(stage, cur);
|
||||||
|
if (SHEET_EXTS.has(ext)) return renderSheet(stage, cur, ext);
|
||||||
|
return renderFallback(stage, cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImage(stage, cur) {
|
||||||
|
stage.classList.add("eav-pan");
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "eav-img";
|
||||||
|
img.alt = cur.filename;
|
||||||
|
img.src = `/expense/api/attachments/${cur.id}`;
|
||||||
|
img.draggable = false;
|
||||||
|
img.onload = () => { applyZoom(); };
|
||||||
|
img.onerror = () => { stage.innerHTML = `<div class="eav-fallback">이미지 로드 실패</div>`; };
|
||||||
|
stage.appendChild(img);
|
||||||
|
state.image = img;
|
||||||
|
|
||||||
|
// 마우스 드래그 패닝
|
||||||
|
stage.addEventListener("pointerdown", (e) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
state.panning = true;
|
||||||
|
state.panStartX = e.clientX;
|
||||||
|
state.panStartY = e.clientY;
|
||||||
|
state.scrollStartLeft = stage.scrollLeft;
|
||||||
|
state.scrollStartTop = stage.scrollTop;
|
||||||
|
stage.setPointerCapture(e.pointerId);
|
||||||
|
stage.classList.add("is-panning");
|
||||||
|
});
|
||||||
|
stage.addEventListener("pointermove", (e) => {
|
||||||
|
if (!state.panning) return;
|
||||||
|
stage.scrollLeft = state.scrollStartLeft - (e.clientX - state.panStartX);
|
||||||
|
stage.scrollTop = state.scrollStartTop - (e.clientY - state.panStartY);
|
||||||
|
});
|
||||||
|
const endPan = (e) => {
|
||||||
|
if (!state.panning) return;
|
||||||
|
state.panning = false;
|
||||||
|
try { stage.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||||
|
stage.classList.remove("is-panning");
|
||||||
|
};
|
||||||
|
stage.addEventListener("pointerup", endPan);
|
||||||
|
stage.addEventListener("pointercancel", endPan);
|
||||||
|
stage.addEventListener("pointerleave", endPan);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoom() {
|
||||||
|
if (!state.image) return;
|
||||||
|
const stage = overlay.querySelector('[data-role="stage"]');
|
||||||
|
const img = state.image;
|
||||||
|
if (state.zoom === "fit") {
|
||||||
|
img.style.maxWidth = "100%";
|
||||||
|
img.style.maxHeight = "calc(100vh - 200px)";
|
||||||
|
img.style.width = "";
|
||||||
|
img.style.height = "";
|
||||||
|
} else {
|
||||||
|
img.style.maxWidth = "none";
|
||||||
|
img.style.maxHeight = "none";
|
||||||
|
img.style.width = (img.naturalWidth * state.zoom) + "px";
|
||||||
|
img.style.height = "auto";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setZoom(z) {
|
||||||
|
state.zoom = z;
|
||||||
|
applyZoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSheet(stage, cur, ext) {
|
||||||
|
stage.classList.remove("eav-pan");
|
||||||
|
stage.innerHTML = `<div class="eav-sheet-loading">시트 로드 중…</div>`;
|
||||||
|
try {
|
||||||
|
const XLSX = await loadXlsxLib();
|
||||||
|
const res = await fetch(`/expense/api/attachments/${cur.id}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
const wb = XLSX.read(buf, { type: "array" });
|
||||||
|
stage.innerHTML = "";
|
||||||
|
|
||||||
|
const sheetWrap = document.createElement("div");
|
||||||
|
sheetWrap.className = "eav-sheet-wrap";
|
||||||
|
|
||||||
|
const tabs = document.createElement("div");
|
||||||
|
tabs.className = "eav-sheet-tabs";
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "eav-sheet-body";
|
||||||
|
|
||||||
|
function showSheet(name) {
|
||||||
|
const ws = wb.Sheets[name];
|
||||||
|
const html = XLSX.utils.sheet_to_html(ws, { editable: false });
|
||||||
|
body.innerHTML = html;
|
||||||
|
tabs.querySelectorAll("button").forEach((b) => {
|
||||||
|
b.classList.toggle("is-active", b.dataset.sheet === name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wb.SheetNames.forEach((name) => {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "eav-btn";
|
||||||
|
b.dataset.sheet = name;
|
||||||
|
b.textContent = name;
|
||||||
|
b.onclick = () => showSheet(name);
|
||||||
|
tabs.appendChild(b);
|
||||||
|
});
|
||||||
|
sheetWrap.appendChild(tabs);
|
||||||
|
sheetWrap.appendChild(body);
|
||||||
|
stage.appendChild(sheetWrap);
|
||||||
|
showSheet(wb.SheetNames[0]);
|
||||||
|
} catch (err) {
|
||||||
|
stage.innerHTML = `<div class="eav-fallback">시트 로드 실패: ${escapeHtml(err.message || String(err))}
|
||||||
|
<br/><a href="/expense/api/attachments/${cur.id}" target="_blank">원본 다운로드</a></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFallback(stage, cur) {
|
||||||
|
stage.classList.remove("eav-pan");
|
||||||
|
stage.innerHTML = `
|
||||||
|
<div class="eav-fallback">
|
||||||
|
미리보기를 지원하지 않는 파일입니다.<br/>
|
||||||
|
<a href="/expense/api/attachments/${cur.id}" target="_blank">다운로드 / 새 창 열기</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSize(b) {
|
||||||
|
b = Number(b || 0);
|
||||||
|
if (b < 1024) return `${b} B`;
|
||||||
|
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(b / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s || "").replace(/[&<>"']/g, (c) =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(items, opts) {
|
||||||
|
ensureOverlay();
|
||||||
|
state.items = items || [];
|
||||||
|
state.currentIdx = state.items.length ? 0 : -1;
|
||||||
|
state.zoom = 1;
|
||||||
|
state.title = (opts && opts.title) || "첨부 보기";
|
||||||
|
overlay.querySelector('[data-role="title"]').textContent = state.title;
|
||||||
|
overlay.dataset.open = "true";
|
||||||
|
buildList();
|
||||||
|
buildToolbar();
|
||||||
|
renderCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.dataset.open = "false";
|
||||||
|
state.items = [];
|
||||||
|
state.currentIdx = -1;
|
||||||
|
state.image = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFor(itemId, opts) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/expense/api/items/${itemId}/attachments`);
|
||||||
|
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||||
|
const { attachments } = await res.json();
|
||||||
|
if (!attachments || !attachments.length) {
|
||||||
|
alert("첨부 파일이 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
open(attachments, opts);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`첨부 로드 실패: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ErpAttachViewer = { open, openFor, close };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
/* ════════════════════════════════════════════════════
|
||||||
|
ERP Shell — 좌측 사이드바 + 우측 콘텐츠 레이아웃
|
||||||
|
erp.css 의 토큰을 그대로 사용한다. 같이 로드 필수.
|
||||||
|
════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--erp-sidebar-w: 240px;
|
||||||
|
--erp-sidebar-w-collapsed: 64px;
|
||||||
|
--erp-topbar-h: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 페이지 전체 컨테이너 ──
|
||||||
|
뷰포트(예: 1883×938)에 고정 — 문서 전체 세로 스크롤 제거.
|
||||||
|
넘치는 콘텐츠는 .erp-page 내부에서만 처리한다(CORM/Order 는 별도 창이라 무관). */
|
||||||
|
body.erp-app-body { background: var(--color-canvas-white); height: 100vh; overflow: hidden; }
|
||||||
|
|
||||||
|
.erp-app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--erp-sidebar-w) 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
transition: grid-template-columns .18s ease;
|
||||||
|
}
|
||||||
|
.erp-app:has(.erp-sidebar[data-collapsed="true"]) {
|
||||||
|
grid-template-columns: var(--erp-sidebar-w-collapsed) 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 사이드바 ── */
|
||||||
|
.erp-sidebar {
|
||||||
|
position: sticky; top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
border-right: 1px solid var(--color-subtle-ash);
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-sidebar-head {
|
||||||
|
height: var(--erp-topbar-h);
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0 var(--sp-12) 0 var(--sp-16);
|
||||||
|
border-bottom: 1px solid var(--color-subtle-ash);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.erp-sidebar-brand {
|
||||||
|
display: inline-flex; align-items: center; gap: var(--sp-8);
|
||||||
|
color: var(--color-deep-black);
|
||||||
|
}
|
||||||
|
.erp-sidebar-logo { height: 22px; width: auto; display: block; }
|
||||||
|
.erp-sidebar-system {
|
||||||
|
font-weight: 600; font-size: 14px; letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
.erp-sidebar[data-collapsed="true"] .erp-sidebar-system { display: none; }
|
||||||
|
|
||||||
|
.erp-sidebar-toggle {
|
||||||
|
width: 28px; height: 28px; border-radius: 8px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: transparent; border: 1px solid transparent; cursor: pointer;
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
}
|
||||||
|
.erp-sidebar-toggle:hover { background: var(--color-ghost-gray); color: var(--color-deep-black); }
|
||||||
|
.erp-sidebar[data-collapsed="true"] .erp-sidebar-toggle svg { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.erp-sidebar-nav {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: var(--sp-12) var(--sp-8);
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-sidebar-group {
|
||||||
|
font-size: 12px; font-weight: 600;
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
padding: var(--sp-12) var(--sp-12) var(--sp-6);
|
||||||
|
}
|
||||||
|
.erp-sidebar[data-collapsed="true"] .erp-sidebar-group { opacity: 0; height: 0; padding: 0; pointer-events: none; }
|
||||||
|
|
||||||
|
.erp-sidebar-item {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 11px var(--sp-12);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--color-rich-black);
|
||||||
|
font-size: 16px; font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .12s ease, color .12s ease;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* 실행중(is-active) 메뉴는 hover 시 변화 없음. 그 외 메뉴만 진한 회색. */
|
||||||
|
.erp-sidebar-item:hover:not(.is-disabled):not(.is-active) { background: #d9d9d9; }
|
||||||
|
.erp-sidebar-item.is-active {
|
||||||
|
background: var(--color-deep-black);
|
||||||
|
color: var(--color-canvas-white);
|
||||||
|
}
|
||||||
|
.erp-sidebar-item.is-disabled {
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.erp-sidebar-icon {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.erp-sidebar-icon svg { width: 21px; height: 21px; }
|
||||||
|
.erp-sidebar-label { flex: 1; }
|
||||||
|
.erp-sidebar-badge {
|
||||||
|
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
|
||||||
|
padding: 2px 8px; border-radius: var(--r-badge);
|
||||||
|
font-size: 11px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.erp-sidebar-item.is-active .erp-sidebar-badge {
|
||||||
|
background: rgba(255,255,255,0.18); color: var(--color-canvas-white);
|
||||||
|
}
|
||||||
|
.erp-sidebar-ext { color: var(--color-midtone-gray); display: inline-flex; }
|
||||||
|
|
||||||
|
.erp-sidebar[data-collapsed="true"] .erp-sidebar-badge,
|
||||||
|
.erp-sidebar[data-collapsed="true"] .erp-sidebar-ext {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* 접힌 상태: 아이콘 위 / 프로그램 이름 작은 글씨 아래 */
|
||||||
|
.erp-sidebar[data-collapsed="true"] .erp-sidebar-item {
|
||||||
|
flex-direction: column; gap: 3px; padding: 8px 2px;
|
||||||
|
justify-content: center; text-align: center;
|
||||||
|
}
|
||||||
|
.erp-sidebar[data-collapsed="true"] .erp-sidebar-label {
|
||||||
|
flex: none; font-size: 10px; line-height: 1.1; font-weight: 500;
|
||||||
|
white-space: normal; word-break: keep-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-sidebar-foot {
|
||||||
|
padding: var(--sp-8);
|
||||||
|
border-top: 1px solid var(--color-subtle-ash);
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 콘텐츠 영역 ── */
|
||||||
|
.erp-content { display: flex; flex-direction: column; min-width: 0; height: 100vh; min-height: 0; }
|
||||||
|
|
||||||
|
.erp-topbar {
|
||||||
|
height: var(--erp-topbar-h); flex-shrink: 0;
|
||||||
|
position: sticky; top: 0; z-index: 30;
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
border-bottom: 1px solid var(--color-subtle-ash);
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0 var(--sp-24);
|
||||||
|
gap: var(--sp-16);
|
||||||
|
}
|
||||||
|
.erp-topbar-left { display: flex; align-items: center; gap: var(--sp-12); min-width: 0; }
|
||||||
|
.erp-topbar-title h1 {
|
||||||
|
font-size: 16px; font-weight: 600; margin: 0; line-height: 1.2;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
.erp-topbar-title p {
|
||||||
|
margin: 2px 0 0; color: var(--color-midtone-gray); font-size: 12px;
|
||||||
|
}
|
||||||
|
.erp-topbar-right { display: flex; align-items: center; gap: var(--sp-12); }
|
||||||
|
|
||||||
|
.erp-sidebar-mobile-toggle {
|
||||||
|
display: none;
|
||||||
|
width: 32px; height: 32px; border-radius: 8px;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
background: transparent; border: 1px solid var(--color-subtle-ash); cursor: pointer;
|
||||||
|
color: var(--color-rich-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-page {
|
||||||
|
padding: var(--sp-24);
|
||||||
|
max-width: 1550px; width: 100%; margin: 0 auto;
|
||||||
|
flex: 1 1 auto; min-height: 0;
|
||||||
|
overflow-y: auto; /* 넘치면 페이지 영역만 스크롤(문서 전체 X) */
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
/* 달력 등 "한 화면 채움" 페이지: 첫 섹션이 남은 높이를 모두 차지 */
|
||||||
|
.erp-page > .vac,
|
||||||
|
.erp-page > .cpg { flex: 1 1 auto; min-height: 0; }
|
||||||
|
|
||||||
|
/* ── 홈 대시보드 타일 ── */
|
||||||
|
.erp-hero { margin: var(--sp-8) 0 var(--sp-24); }
|
||||||
|
.erp-hero-title { font-size: 28px; font-weight: 600; margin: 0; letter-spacing: -0.6px; }
|
||||||
|
.erp-hero-sub { margin: 6px 0 0; color: var(--color-midtone-gray); font-size: 13px; }
|
||||||
|
|
||||||
|
.erp-home-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: var(--sp-16);
|
||||||
|
}
|
||||||
|
.erp-tile {
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
padding: var(--sp-20);
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
}
|
||||||
|
.erp-tile-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.erp-tile-label {
|
||||||
|
font-size: 11px; color: var(--color-midtone-gray); text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.erp-tile-title { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: -0.3px; }
|
||||||
|
.erp-tile-desc { margin: 0 0 8px; color: var(--color-midtone-gray); font-size: 13px; }
|
||||||
|
.erp-tile-list { margin: 0; padding-left: 18px; color: var(--color-rich-black); font-size: 13px; }
|
||||||
|
.erp-tile-list li { margin: 4px 0; }
|
||||||
|
|
||||||
|
/* ── 개인경비 페이지 ── */
|
||||||
|
.erp-expense { display: flex; flex-direction: column; gap: var(--sp-20); }
|
||||||
|
|
||||||
|
.erp-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: var(--sp-12);
|
||||||
|
}
|
||||||
|
.erp-summary-card {
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
padding: var(--sp-16);
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
}
|
||||||
|
.erp-summary-card--mini { padding: var(--sp-12) var(--sp-16); }
|
||||||
|
.erp-summary-label { font-size: 12px; color: var(--color-midtone-gray); }
|
||||||
|
.erp-summary-value { font-size: 20px; font-weight: 600; letter-spacing: -0.3px; font-variant-numeric: tabular-nums; }
|
||||||
|
.erp-summary-value--sm { font-size: 16px; }
|
||||||
|
|
||||||
|
.erp-card-block {
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.erp-card-block-head {
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between;
|
||||||
|
padding: var(--sp-16) var(--sp-20);
|
||||||
|
border-bottom: 1px solid var(--color-subtle-ash);
|
||||||
|
}
|
||||||
|
.erp-card-block-head h2 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: -0.2px; }
|
||||||
|
.erp-muted { color: var(--color-midtone-gray); font-size: 12px; }
|
||||||
|
|
||||||
|
.erp-form-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(4, minmax(0,1fr));
|
||||||
|
gap: var(--sp-12); padding: var(--sp-16) var(--sp-20);
|
||||||
|
}
|
||||||
|
.erp-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--color-midtone-gray); }
|
||||||
|
.erp-field-wide { grid-column: span 2; }
|
||||||
|
.erp-field input, .erp-field select {
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: inherit; font-size: 14px; color: var(--color-rich-black);
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.erp-field input:focus, .erp-field select:focus {
|
||||||
|
border-color: var(--color-rich-black);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.erp-form-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex; justify-content: flex-end; gap: var(--sp-8);
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-btn-sm { padding: 4px 10px !important; font-size: 12px !important; }
|
||||||
|
|
||||||
|
.erp-table-wrap { overflow-x: auto; }
|
||||||
|
.erp-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.erp-table thead th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500; color: var(--color-midtone-gray);
|
||||||
|
padding: 10px var(--sp-16);
|
||||||
|
border-bottom: 1px solid var(--color-subtle-ash);
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
}
|
||||||
|
.erp-table tbody td {
|
||||||
|
padding: 10px var(--sp-16);
|
||||||
|
border-bottom: 1px solid var(--color-subtle-ash);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.erp-table tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
.erp-table tbody tr:hover { background: var(--color-ghost-gray); }
|
||||||
|
.erp-row-sub { font-size: 12px; color: var(--color-midtone-gray); margin-top: 2px; }
|
||||||
|
.erp-empty { text-align: center; color: var(--color-midtone-gray); padding: var(--sp-24); }
|
||||||
|
|
||||||
|
/* ── 반응형 ── */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
/* 모바일: grid 자체 해제(데스크탑 collapsed :has 규칙과의 특이도 싸움 회피).
|
||||||
|
사이드바는 흐름 밖 떠있는 오버레이, 본문은 풀폭 block. */
|
||||||
|
body.erp-app-body { height: auto; overflow: auto; }
|
||||||
|
.erp-app,
|
||||||
|
.erp-app:has(.erp-sidebar[data-collapsed="true"]) { display: block; }
|
||||||
|
.erp-sidebar {
|
||||||
|
position: fixed; left: 0; top: 0; height: 100vh; z-index: 1000;
|
||||||
|
width: var(--erp-sidebar-w);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform .2s ease;
|
||||||
|
box-shadow: 0 0 0 1px var(--color-subtle-ash);
|
||||||
|
}
|
||||||
|
/* 모바일선 데스크탑 접힘 무시 — 메뉴 열면 풀폭으로 */
|
||||||
|
.erp-sidebar[data-collapsed="true"] { width: var(--erp-sidebar-w); }
|
||||||
|
.erp-sidebar.is-open { transform: translateX(0); }
|
||||||
|
.erp-sidebar-mobile-toggle { display: inline-flex; }
|
||||||
|
.erp-content { width: 100%; min-width: 0; }
|
||||||
|
.erp-form-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.erp-field-wide { grid-column: span 2; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.erp-form-grid { grid-template-columns: 1fr; }
|
||||||
|
.erp-field-wide { grid-column: auto; }
|
||||||
|
.erp-page { padding: var(--sp-16); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
/* ════════════════════════════════════════════════════
|
||||||
|
ERP 대시보드 — DESIGN.md 기반 (Monochromatic / shadcn)
|
||||||
|
════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Geist 폰트 — CDN. 실패시 Inter / 시스템 폰트 fallback */
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono&family=Inter:wght@400;500;600&display=swap");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Colors */
|
||||||
|
--color-canvas-white: #ffffff;
|
||||||
|
--color-ghost-gray: #f2f2f2;
|
||||||
|
--color-subtle-ash: #e5e5e5;
|
||||||
|
--color-midtone-gray: #737373;
|
||||||
|
--color-rich-black: #0a0a0a;
|
||||||
|
--color-deep-black: #000000;
|
||||||
|
--color-callout-red: #c22b10;
|
||||||
|
--color-success-green:#10c22b;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-geist: 'Geist', 'Inter', ui-sans-serif, system-ui, -apple-system,
|
||||||
|
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-geist-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo,
|
||||||
|
Monaco, Consolas, monospace;
|
||||||
|
|
||||||
|
--text-caption: 12px;
|
||||||
|
--text-body: 14px;
|
||||||
|
--text-heading: 18px;
|
||||||
|
--text-display: 48px;
|
||||||
|
--leading-body: 1.43;
|
||||||
|
|
||||||
|
--tracking-heading: -0.45px;
|
||||||
|
--tracking-display: -2.4px;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--sp-4: 4px; --sp-6: 6px; --sp-8: 8px; --sp-10: 10px;
|
||||||
|
--sp-12: 12px; --sp-16: 16px; --sp-20: 20px; --sp-24: 24px;
|
||||||
|
--sp-32: 32px; --sp-40: 40px; --sp-80: 80px;
|
||||||
|
|
||||||
|
/* Radii */
|
||||||
|
--r-pill: 9999px;
|
||||||
|
--r-badge: 26px;
|
||||||
|
--r-card: 14px;
|
||||||
|
--r-input: 10px;
|
||||||
|
--r-btn: 10px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-card: oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px;
|
||||||
|
--shadow-focus: lab(100 0 0) 0px 0px 0px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 리셋 ── (auth pages 와 충돌 방지 위해 erp-shell 안에서만 적용) */
|
||||||
|
.erp-shell, .erp-shell * { box-sizing: border-box; }
|
||||||
|
.erp-shell { margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body.erp-body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-geist);
|
||||||
|
font-size: var(--text-body);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
color: var(--color-rich-black);
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-body a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
/* ── 상단 네비게이션 ── */
|
||||||
|
.erp-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
border-bottom: 1px solid var(--color-subtle-ash);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--sp-24);
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-nav-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-8);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-deep-black);
|
||||||
|
font-size: var(--text-body);
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-brand-logo {
|
||||||
|
height: 36px;
|
||||||
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-brand-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--color-subtle-ash);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-brand-system {
|
||||||
|
font-size: var(--text-body);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-rich-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-nav-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-user-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-8);
|
||||||
|
padding: 4px var(--sp-12) 4px 4px;
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
color: var(--color-rich-black);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-user-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-user-avatar-fallback {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-deep-black);
|
||||||
|
color: var(--color-canvas-white);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-user-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-rich-black);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-user-email {
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 버튼 ── */
|
||||||
|
.erp-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sp-6);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--r-btn);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 120ms, border-color 120ms, color 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-btn-primary {
|
||||||
|
background: var(--color-deep-black);
|
||||||
|
color: var(--color-canvas-white);
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-btn-primary:hover {
|
||||||
|
background: var(--color-rich-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 검정 배경 요소는 글자 무조건 흰색.
|
||||||
|
<a> 버튼/배지의 a:link/a:visited(특이도 0,1,1)가 클래스 색을 덮어쓰는 문제 차단. */
|
||||||
|
a.erp-btn-primary, a.erp-btn-primary:link, a.erp-btn-primary:visited, a.erp-btn-primary:hover,
|
||||||
|
.erp-badge-inverse, a.erp-badge-inverse:link, a.erp-badge-inverse:visited,
|
||||||
|
.erp-sidebar-item.is-active, a.erp-sidebar-item.is-active:link, a.erp-sidebar-item.is-active:visited {
|
||||||
|
color: var(--color-canvas-white) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-rich-black);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-btn-ghost:hover {
|
||||||
|
background: var(--color-ghost-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-btn-outline {
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
color: var(--color-rich-black);
|
||||||
|
border-color: var(--color-subtle-ash);
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-btn-outline:hover {
|
||||||
|
background: var(--color-ghost-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-callout-red);
|
||||||
|
border-color: var(--color-subtle-ash);
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-btn-danger:hover {
|
||||||
|
border-color: var(--color-callout-red);
|
||||||
|
background: rgba(194, 43, 16, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 본문 컨테이너 ── */
|
||||||
|
.erp-main {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--sp-40) var(--sp-24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-greeting {
|
||||||
|
margin: 0 0 var(--sp-8);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-deep-black);
|
||||||
|
letter-spacing: -0.6px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-greeting-sub {
|
||||||
|
margin: 0 0 var(--sp-32);
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
font-size: var(--text-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--sp-16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-section-title {
|
||||||
|
font-size: var(--text-heading);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-deep-black);
|
||||||
|
letter-spacing: var(--tracking-heading);
|
||||||
|
line-height: 1.33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-section-meta {
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 카드 그리드 ── */
|
||||||
|
.erp-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: var(--sp-16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-12);
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: var(--sp-20);
|
||||||
|
min-height: 180px;
|
||||||
|
transition: box-shadow 120ms, transform 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-card[data-clickable="true"]:hover {
|
||||||
|
box-shadow: oklab(0.145 -0.00000143796 0.00000340492 / 0.18) 0 0 0 1px,
|
||||||
|
oklab(0.145 -0.00000143796 0.00000340492 / 0.06) 0 8px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-card[data-clickable="false"] {
|
||||||
|
background: var(--color-ghost-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-card-title {
|
||||||
|
font-size: var(--text-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-deep-black);
|
||||||
|
letter-spacing: var(--tracking-heading);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-card-subtitle {
|
||||||
|
font-family: var(--font-geist-mono);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-card-desc {
|
||||||
|
font-size: var(--text-body);
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-card-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-8);
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: var(--sp-12);
|
||||||
|
border-top: 1px solid var(--color-subtle-ash);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-card-link {
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-deep-black);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-card-link svg {
|
||||||
|
transition: transform 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-card[data-clickable="true"]:hover .erp-card-link svg {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 배지 ── */
|
||||||
|
.erp-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-6);
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--r-badge);
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-badge-inverse {
|
||||||
|
background: var(--color-deep-black);
|
||||||
|
color: var(--color-canvas-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-badge-neutral {
|
||||||
|
background: var(--color-ghost-gray);
|
||||||
|
color: var(--color-rich-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-badge-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-rich-black);
|
||||||
|
border: 1px solid #a1a1a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-badge-success { background: #e6f8e9; color: var(--color-success-green); border: 1px solid #c2efc8; }
|
||||||
|
.erp-badge-danger { background: #fbe9e6; color: var(--color-callout-red); border: 1px solid #efc6bf; }
|
||||||
|
|
||||||
|
.erp-badge-dot::before {
|
||||||
|
content: "";
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 잠긴 카드 표시 ── */
|
||||||
|
.erp-locked-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--sp-12);
|
||||||
|
right: var(--sp-12);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 관리자 페이지 ── */
|
||||||
|
.erp-admin-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-12);
|
||||||
|
margin-bottom: var(--sp-16);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-input {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--text-body);
|
||||||
|
color: var(--color-rich-black);
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
padding: 6px var(--sp-10);
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-input:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--shadow-focus);
|
||||||
|
border-color: var(--color-deep-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-table-wrap {
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-table thead th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
background: var(--color-ghost-gray);
|
||||||
|
padding: var(--sp-10) var(--sp-12);
|
||||||
|
border-bottom: 1px solid var(--color-subtle-ash);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-table tbody td {
|
||||||
|
padding: var(--sp-12);
|
||||||
|
border-bottom: 1px solid var(--color-subtle-ash);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-table tbody tr[data-dirty="true"] {
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-user-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-user-cell .erp-user-avatar,
|
||||||
|
.erp-user-cell .erp-user-avatar-fallback {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-user-cell-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-rich-black);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-user-cell-email {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* shadcn-style 토글 (checkbox 기반) */
|
||||||
|
.erp-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.erp-switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.erp-switch-slider {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-subtle-ash);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms;
|
||||||
|
}
|
||||||
|
.erp-switch-slider::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
left: 2px; top: 2px;
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 120ms;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
.erp-switch input:checked + .erp-switch-slider { background: var(--color-deep-black); }
|
||||||
|
.erp-switch input:checked + .erp-switch-slider::before { transform: translateX(16px); }
|
||||||
|
.erp-switch input:disabled + .erp-switch-slider {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* shadcn-style select */
|
||||||
|
.erp-select {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--text-body);
|
||||||
|
color: var(--color-rich-black);
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
padding: 4px var(--sp-10);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-select:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--shadow-focus);
|
||||||
|
border-color: var(--color-deep-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-select:disabled {
|
||||||
|
background: var(--color-ghost-gray);
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 토스트 */
|
||||||
|
.erp-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
background: var(--color-deep-black);
|
||||||
|
color: var(--color-canvas-white);
|
||||||
|
padding: var(--sp-12) var(--sp-16);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
font-size: var(--text-body);
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transition: opacity 200ms, transform 200ms;
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.erp-toast[data-visible="true"] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.erp-toast[data-type="error"] {
|
||||||
|
background: var(--color-callout-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 반응형 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.erp-nav { padding: 0 var(--sp-16); height: 52px; }
|
||||||
|
.erp-brand-system { display: none; }
|
||||||
|
.erp-user-info { display: none; }
|
||||||
|
.erp-main { padding: var(--sp-24) var(--sp-16); }
|
||||||
|
.erp-greeting { font-size: 22px; }
|
||||||
|
.erp-table thead { display: none; }
|
||||||
|
.erp-table tbody td { display: block; border: none; padding: 4px var(--sp-12); }
|
||||||
|
.erp-table tbody tr { display: block; padding: var(--sp-12) 0; border-bottom: 1px solid var(--color-subtle-ash); }
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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") + " × "
|
||||||
|
+ "<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 = ""; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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; }
|
||||||
@@ -1,25 +1,5 @@
|
|||||||
/* ── Pretendard 폰트 ─────────────────────────────── */
|
/* ── Pretendard 폰트 (CDN) ───────────────────────── */
|
||||||
@font-face {
|
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard/packages/pretendard/dist/web/static/pretendard.css");
|
||||||
font-family: "Pretendard";
|
|
||||||
src: url("/static/fonts/Pretendard-Thin.ttf") format("truetype");
|
|
||||||
font-weight: 100;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Pretendard";
|
|
||||||
src: url("/static/fonts/Pretendard-Regular.ttf") format("truetype");
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Pretendard";
|
|
||||||
src: url("/static/fonts/Pretendard-Bold.ttf") format("truetype");
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 디자인 토큰 ─────────────────────────────────── */
|
/* ── 디자인 토큰 ─────────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
@@ -36,7 +16,7 @@
|
|||||||
--muted: #627590;
|
--muted: #627590;
|
||||||
--success: #127a46;
|
--success: #127a46;
|
||||||
--danger: #b91c1c;
|
--danger: #b91c1c;
|
||||||
--header-h: 64px;
|
--header-h: 55px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 리셋 & 기본 ─────────────────────────────────── */
|
/* ── 리셋 & 기본 ─────────────────────────────────── */
|
||||||
@@ -44,7 +24,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "Pretendard", "Segoe UI", "Apple SD Gothic Neo", system-ui, sans-serif;
|
font-family: "Pretendard", "Segoe UI", "Apple SD Gothic Neo", system-ui, sans-serif;
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -107,8 +87,18 @@ a { color: inherit; text-decoration: none; }
|
|||||||
|
|
||||||
.brand-logo-mark svg { color: #fff; }
|
.brand-logo-mark svg { color: #fff; }
|
||||||
|
|
||||||
|
.brand-logo-img {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo-img--full {
|
||||||
|
width: 284px;
|
||||||
|
height: 284px;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-name {
|
.brand-name {
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
letter-spacing: -.3px;
|
letter-spacing: -.3px;
|
||||||
@@ -134,27 +124,40 @@ a { color: inherit; text-decoration: none; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-headline {
|
.brand-headline {
|
||||||
font-size: clamp(28px, 3.2vw, 42px);
|
font-size: clamp(32px, 3.6vw, 48px);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
line-height: 1.25;
|
line-height: 1.2;
|
||||||
letter-spacing: -.5px;
|
letter-spacing: -.5px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-desc {
|
.brand-desc {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: rgba(255,255,255,.55);
|
color: rgba(255,255,255,.55);
|
||||||
line-height: 1.7;
|
line-height: 1.8;
|
||||||
max-width: 380px;
|
}
|
||||||
|
|
||||||
|
.brand-desc-nowrap {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255,255,255,.55);
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-divider {
|
.brand-divider {
|
||||||
width: 48px;
|
width: 10px;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: var(--blue);
|
background: var(--blue);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
margin-top: 28px;
|
margin-top: 28px;
|
||||||
|
animation: divider-breathe 24s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes divider-breathe {
|
||||||
|
0% { width: 10px; }
|
||||||
|
50% { width: 50%; }
|
||||||
|
100% { width: 10px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-footer {
|
.brand-footer {
|
||||||
@@ -186,16 +189,16 @@ a { color: inherit; text-decoration: none; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-title {
|
.form-title {
|
||||||
font-size: clamp(22px, 2.8vw, 30px);
|
font-size: clamp(24px, 3vw, 32px);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--navy);
|
color: var(--navy);
|
||||||
letter-spacing: -.4px;
|
letter-spacing: -.4px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-subtitle {
|
.form-subtitle {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin-bottom: 36px;
|
margin-bottom: 36px;
|
||||||
@@ -322,6 +325,14 @@ a { color: inherit; text-decoration: none; }
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-brand-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: -.2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.header-logo-mark {
|
.header-logo-mark {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -335,6 +346,13 @@ a { color: inherit; text-decoration: none; }
|
|||||||
|
|
||||||
.header-logo-mark svg { color: #fff; }
|
.header-logo-mark svg { color: #fff; }
|
||||||
|
|
||||||
|
.header-logo-img {
|
||||||
|
height: 55px;
|
||||||
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.header-brand-text {
|
.header-brand-text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -349,9 +367,10 @@ a { color: inherit; text-decoration: none; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-portal {
|
.header-portal {
|
||||||
font-size: 11px;
|
font-size: 24px;
|
||||||
color: rgba(255,255,255,.45);
|
font-weight: 700;
|
||||||
font-weight: 400;
|
color: #ffffff;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-user {
|
.header-user {
|
||||||
@@ -561,16 +580,16 @@ a { color: inherit; text-decoration: none; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 17px;
|
font-size: 19px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--navy);
|
color: var(--navy);
|
||||||
letter-spacing: -.3px;
|
letter-spacing: -.3px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-desc {
|
.card-desc {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -598,6 +617,160 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.menu-card:hover .card-link { gap: 8px; }
|
.menu-card:hover .card-link { gap: 8px; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════
|
||||||
|
브랜드 패널 — 움직이는 도형
|
||||||
|
════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.brand-shapes {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape { position: absolute; }
|
||||||
|
|
||||||
|
/* 1. 큰 원 테두리 — 우측 상단 */
|
||||||
|
.shape-1 {
|
||||||
|
width: 360px;
|
||||||
|
height: 360px;
|
||||||
|
border: 3px solid rgba(30, 86, 160, .28);
|
||||||
|
border-radius: 50%;
|
||||||
|
top: -90px;
|
||||||
|
right: -70px;
|
||||||
|
animation: shapeFloat1 16s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. 다이아몬드 — 좌측 하단 */
|
||||||
|
.shape-2 {
|
||||||
|
width: 130px;
|
||||||
|
height: 130px;
|
||||||
|
background: rgba(30, 86, 160, .12);
|
||||||
|
border: 3px solid rgba(30, 86, 160, .3);
|
||||||
|
border-radius: 20px;
|
||||||
|
bottom: 90px;
|
||||||
|
left: -45px;
|
||||||
|
animation: shapeFloat2 13s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. 중간 원 — 중앙 우측 */
|
||||||
|
.shape-3 {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, .14);
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 40%;
|
||||||
|
right: 12%;
|
||||||
|
animation: shapeFloat3 11s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. 둥근 사각형 테두리 — 하단 */
|
||||||
|
.shape-4 {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border: 3px solid rgba(30, 86, 160, .22);
|
||||||
|
border-radius: 36px;
|
||||||
|
bottom: -60px;
|
||||||
|
right: 16%;
|
||||||
|
animation: shapeFloat4 15s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 5. 작은 원 채움 — 상단 좌측 */
|
||||||
|
.shape-5 {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
background: rgba(30, 86, 160, .2);
|
||||||
|
border: 2px solid rgba(30, 86, 160, .35);
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 20%;
|
||||||
|
left: 16%;
|
||||||
|
animation: shapeFloat5 9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 6. 세모 — 우측 중하단 */
|
||||||
|
.shape-6 {
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
background: rgba(30, 86, 160, .14);
|
||||||
|
clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
|
||||||
|
bottom: 28%;
|
||||||
|
right: 6%;
|
||||||
|
animation: shapeFloat6 12s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* King 텍스트 — 반짝이며 유동 */
|
||||||
|
.shape-king {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, .9);
|
||||||
|
letter-spacing: 4px;
|
||||||
|
text-transform: none;
|
||||||
|
top: 28%;
|
||||||
|
right: 7%;
|
||||||
|
animation: kingFloat 13s ease-in-out infinite, kingSparkle 2.4s ease-in-out infinite;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shapeFloat1 {
|
||||||
|
0%, 100% { transform: translate(0, 0); }
|
||||||
|
25% { transform: translate(-22px, 32px); }
|
||||||
|
50% { transform: translate(16px, 22px); }
|
||||||
|
75% { transform: translate(-10px, -18px); }
|
||||||
|
}
|
||||||
|
@keyframes shapeFloat2 {
|
||||||
|
0%, 100% { transform: rotate(45deg) translate(0, 0); }
|
||||||
|
33% { transform: rotate(68deg) translate(22px, -26px); }
|
||||||
|
66% { transform: rotate(28deg) translate(-18px, 22px); }
|
||||||
|
}
|
||||||
|
@keyframes shapeFloat3 {
|
||||||
|
0%, 100% { transform: translate(0, 0); }
|
||||||
|
50% { transform: translate(28px, -34px); }
|
||||||
|
}
|
||||||
|
@keyframes shapeFloat4 {
|
||||||
|
0%, 100% { transform: rotate(12deg) translate(0, 0); }
|
||||||
|
40% { transform: rotate(24deg) translate(-22px, -14px); }
|
||||||
|
80% { transform: rotate(2deg) translate(18px, 10px); }
|
||||||
|
}
|
||||||
|
@keyframes shapeFloat5 {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
|
33% { transform: translate(16px, -20px) scale(1.2); }
|
||||||
|
66% { transform: translate(-12px, 14px) scale(0.85); }
|
||||||
|
}
|
||||||
|
@keyframes shapeFloat6 {
|
||||||
|
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
||||||
|
33% { transform: translate(-18px, -22px) rotate(20deg); }
|
||||||
|
66% { transform: translate(14px, 10px) rotate(-15deg); }
|
||||||
|
}
|
||||||
|
@keyframes shapeFloat7 {
|
||||||
|
0%, 100% { transform: translate(0, 0) rotate(0deg) scale(1); }
|
||||||
|
33% { transform: translate(-20px, -18px) rotate(36deg) scale(1.15); }
|
||||||
|
66% { transform: translate(14px, 16px) rotate(-18deg) scale(0.9); }
|
||||||
|
}
|
||||||
|
@keyframes kingFloat {
|
||||||
|
0%, 100% { transform: translate(0, 0); }
|
||||||
|
20% { transform: translate(-65px, 50px); }
|
||||||
|
40% { transform: translate(55px, -40px); }
|
||||||
|
60% { transform: translate(-45px, -60px); }
|
||||||
|
80% { transform: translate(70px, 30px); }
|
||||||
|
}
|
||||||
|
@keyframes kingSparkle {
|
||||||
|
0%, 100% {
|
||||||
|
text-shadow:
|
||||||
|
0 0 8px rgba(255, 255, 255, .6),
|
||||||
|
0 0 18px rgba(99, 179, 237, .5);
|
||||||
|
opacity: .75;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
text-shadow:
|
||||||
|
0 0 16px rgba(255, 255, 255, 1),
|
||||||
|
0 0 36px rgba(99, 179, 237, 1),
|
||||||
|
0 0 56px rgba(147, 197, 253, .9),
|
||||||
|
0 0 80px rgba(59, 130, 246, .6);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════
|
||||||
반응형
|
반응형
|
||||||
════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════ */
|
||||||
@@ -623,3 +796,37 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.header-portal { display: none; }
|
.header-portal { display: none; }
|
||||||
.btn-logout span { display: none; }
|
.btn-logout span { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 상태 배지 ─────────────────────────────────── */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
vertical-align: middle;
|
||||||
|
line-height: 1.4;
|
||||||
|
background: #eef2f7;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.status-badge::before {
|
||||||
|
content: "";
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
.status-badge[data-status="online"] {
|
||||||
|
background: #e7f6ee;
|
||||||
|
color: var(--success);
|
||||||
|
border-color: #b6e0c6;
|
||||||
|
}
|
||||||
|
.status-badge[data-status="offline"] {
|
||||||
|
background: #fdecec;
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: #f1c2c2;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
/* 휴가 관리(vacation) 모듈 스타일.
|
||||||
|
DESIGN.md 토큰 준수 — 모노크롬, 10/14px radius, 카드 padding 16px.
|
||||||
|
캐시 버전: ?v=20260530a (변경 시 템플릿 head_extra 의 ?v= 도 함께 올린다) */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--vac-ash: #e5e5e5;
|
||||||
|
--vac-ghost: #f2f2f2;
|
||||||
|
--vac-muted: #737373;
|
||||||
|
--vac-black: #0a0a0a;
|
||||||
|
--vac-red: #c22b10;
|
||||||
|
--vac-blue: #1d4ed8;
|
||||||
|
--vac-green: #10733a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vac { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* 검정 배경 버튼 글자 흰색 강제 */
|
||||||
|
.vac .erp-btn-primary { color: #fff !important; }
|
||||||
|
|
||||||
|
/* ── 페이지 액션 + 잔여 요약 ── */
|
||||||
|
.vac-actions {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 12px; flex-wrap: wrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.vac-actions-main { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.vac-mini { margin-left: 4px; }
|
||||||
|
.vac-balance {
|
||||||
|
display: flex; align-items: baseline; gap: 8px;
|
||||||
|
font-size: 14px; color: var(--vac-black);
|
||||||
|
}
|
||||||
|
.vac-balance strong { font-weight: 600; }
|
||||||
|
.vac-bal-sep { color: var(--vac-ash); }
|
||||||
|
.vac-bal-remain strong { color: var(--vac-green); }
|
||||||
|
.vac-bal-year { margin-left: 6px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* ── 레이아웃: 달력 좌 / 리스트 우 ── */
|
||||||
|
.vac-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 340px;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: 1 1 auto; min-height: 0; /* 남은 높이 채움 → 문서 스크롤 방지 */
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.vac-layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 달력 카드 ── */
|
||||||
|
.vac-cal-card {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex; flex-direction: column; min-height: 0; overflow: hidden;
|
||||||
|
}
|
||||||
|
.vac-cal-head {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.vac-cal-title { font-size: 18pt; font-weight: 600; margin: 0; letter-spacing: -0.45px; }
|
||||||
|
.vac-nav-btn { padding: 4px 12px; font-size: 18pt; line-height: 1; }
|
||||||
|
.vac-today-btn { margin-left: auto; }
|
||||||
|
|
||||||
|
.vac-cal {
|
||||||
|
border: 1px solid var(--vac-ash); border-radius: 10px; overflow: hidden;
|
||||||
|
flex: 1 1 auto; min-height: 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vac-wd-row {
|
||||||
|
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
background: var(--vac-ghost); border-bottom: 1px solid var(--vac-ash);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.vac-wd {
|
||||||
|
text-align: center; padding: 8px 0; font-size: 15px; font-weight: 600;
|
||||||
|
color: var(--vac-black);
|
||||||
|
border-right: 1px solid var(--vac-ash);
|
||||||
|
}
|
||||||
|
.vac-wd:last-child { border-right: none; }
|
||||||
|
|
||||||
|
/* 주(week) — 날짜 셀 위에 bar 레이어를 오버레이. 6주가 높이를 균등 분할(유동). */
|
||||||
|
.vac-week {
|
||||||
|
position: relative; border-bottom: 1px solid var(--vac-ash);
|
||||||
|
flex: 1 1 0; min-height: 88px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.vac-week:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.vac-week-days {
|
||||||
|
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
flex: 1 1 auto; min-height: 0;
|
||||||
|
}
|
||||||
|
.vac-day {
|
||||||
|
height: 100%;
|
||||||
|
border-right: 1px solid var(--vac-ash);
|
||||||
|
padding: 6px 8px; text-decoration: none;
|
||||||
|
display: block; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.vac-day:last-child { border-right: none; }
|
||||||
|
.vac-day-num { font-size: 20px; font-weight: 600; color: var(--vac-black); }
|
||||||
|
.vac-out { background: #fafafa; }
|
||||||
|
.vac-out .vac-day-num { color: #bbb; }
|
||||||
|
.vac-today { background: #f5f5f5; }
|
||||||
|
.vac-today .vac-day-num {
|
||||||
|
background: var(--vac-black);
|
||||||
|
color: #fff !important; /* vac-red/vac-blue 글자색(!important) 이김 */
|
||||||
|
border-radius: 9999px; padding: 2px 9px;
|
||||||
|
}
|
||||||
|
/* 오늘이 일/공휴일=빨강배경, 토요일=파랑배경, 평일=검정배경 (모두 흰글씨) */
|
||||||
|
.vac-today .vac-day-num.vac-red { background: var(--vac-red); }
|
||||||
|
.vac-today .vac-day-num.vac-blue { background: var(--vac-blue); }
|
||||||
|
.vac-selected { box-shadow: inset 0 0 0 2px var(--vac-black); }
|
||||||
|
.vac-red { color: var(--vac-red) !important; }
|
||||||
|
.vac-blue { color: var(--vac-blue) !important; }
|
||||||
|
|
||||||
|
/* bar 오버레이 — 날짜 숫자 아래(top)부터 7열 그리드로 겹쳐 그림 */
|
||||||
|
.vac-week-bars {
|
||||||
|
position: absolute; left: 0; right: 0; top: 38px; bottom: 4px;
|
||||||
|
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
grid-auto-rows: 22px; row-gap: 3px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 휴가 bar (구글 달력 스타일) ── */
|
||||||
|
.vac-bar {
|
||||||
|
margin: 0 3px; padding: 0 8px; height: 20px; line-height: 20px;
|
||||||
|
border-radius: 6px; font-size: 13px; text-decoration: none;
|
||||||
|
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
||||||
|
align-self: center; pointer-events: auto;
|
||||||
|
}
|
||||||
|
.vac-bar-label { pointer-events: none; }
|
||||||
|
.vac-bar-l { border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: 0; }
|
||||||
|
.vac-bar-r { border-top-right-radius: 0; border-bottom-right-radius: 0; margin-right: 0; }
|
||||||
|
|
||||||
|
.vac-bar-draft { background: #fff; border: 1px dashed var(--vac-muted); color: var(--vac-muted); }
|
||||||
|
.vac-bar-submit { background: var(--vac-ghost); border: 1px solid var(--vac-muted); color: var(--vac-black); }
|
||||||
|
.vac-bar-approve, a.vac-bar-approve:link, a.vac-bar-approve:visited { background: var(--vac-black); color: #fff; }
|
||||||
|
.vac-bar-reject { background: #fff; border: 1px solid var(--vac-red); color: var(--vac-red); text-decoration: line-through; }
|
||||||
|
.vac-bar-cancel { background: #fff; border: 1px dashed var(--vac-ash); color: #bbb; opacity: 0.7; }
|
||||||
|
|
||||||
|
/* 범례 */
|
||||||
|
.vac-legend { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; flex-shrink: 0; }
|
||||||
|
.vac-leg { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--vac-muted); }
|
||||||
|
.vac-dot { width: 14px; height: 12px; border-radius: 4px; display: inline-block; }
|
||||||
|
|
||||||
|
/* ── 선택일 리스트 ── */
|
||||||
|
.vac-list-card {
|
||||||
|
padding: 16px; min-height: 0; overflow-y: auto; /* 긴 목록은 패널 내부 스크롤 */
|
||||||
|
}
|
||||||
|
.vac-list-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; }
|
||||||
|
.vac-list-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||||
|
.vac-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.vac-list-link {
|
||||||
|
display: block; text-decoration: none; color: var(--vac-black);
|
||||||
|
border: 1px solid var(--vac-ash); border-radius: 10px; padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.vac-list-link:hover { background: var(--vac-ghost); }
|
||||||
|
.vac-list-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.vac-list-meta { font-size: 12px; margin-top: 4px; }
|
||||||
|
.vac-list-reason { font-size: 12px; margin-top: 4px; }
|
||||||
|
.vac-empty { padding: 12px 0; }
|
||||||
|
|
||||||
|
/* ── 신청 폼 ── */
|
||||||
|
.vac-form-card { padding: 16px; max-width: 760px; }
|
||||||
|
.vac-form-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.vac-full { grid-column: 1 / -1; }
|
||||||
|
.vac-days-preview { margin: 12px 0; font-size: 14px; }
|
||||||
|
.vac-days-preview strong { font-size: 16px; font-weight: 600; }
|
||||||
|
.vac-form-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ── 상세 ── */
|
||||||
|
.vac-detail-card { padding: 16px; max-width: 760px; }
|
||||||
|
.vac-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.vac-detail-head h2 { font-size: 18px; font-weight: 600; margin: 0; }
|
||||||
|
.vac-detail-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px 24px; margin: 16px 0;
|
||||||
|
}
|
||||||
|
.vac-detail-grid dt { font-size: 12px; color: var(--vac-muted); margin-bottom: 2px; }
|
||||||
|
.vac-detail-grid dd { margin: 0; font-size: 14px; }
|
||||||
|
.vac-detail-block { margin: 12px 0; }
|
||||||
|
.vac-detail-label { font-size: 12px; color: var(--vac-muted); margin-bottom: 4px; }
|
||||||
|
.vac-reject-block p { color: var(--vac-red); }
|
||||||
|
.vac-approve-box {
|
||||||
|
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
|
||||||
|
background: var(--vac-ghost); border-radius: 10px; padding: 12px; margin: 12px 0;
|
||||||
|
}
|
||||||
|
.vac-reject-form { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.vac-inline-form { display: inline; }
|
||||||
|
.vac-detail-actions { display: flex; gap: 8px; margin-top: 16px; align-items: center; }
|
||||||
|
.vac-push-right { margin-left: auto; }
|
||||||
|
|
||||||
|
/* ── 승인 대기 ── */
|
||||||
|
.vac-pending-table .vac-act-col { width: 280px; }
|
||||||
|
.vac-pending-acts { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.vac-reason-cell { max-width: 260px; }
|
||||||
|
.erp-btn-sm { padding: 2px 10px; font-size: 12px; }
|
||||||
|
.erp-input-sm { padding: 2px 8px; font-size: 12px; min-width: 140px; }
|
||||||
|
|
||||||
|
/* ── 설정 ── */
|
||||||
|
.vac-settings-head { margin-bottom: 4px; }
|
||||||
|
.vac-year-form { display: flex; gap: 8px; align-items: flex-end; }
|
||||||
|
.vac-year-field { max-width: 140px; }
|
||||||
|
.vac-settings-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) { .vac-settings-grid { grid-template-columns: 1fr; } }
|
||||||
|
.vac-set-card { padding: 16px; }
|
||||||
|
.vac-card-head h2 { font-size: 16px; font-weight: 600; margin: 0 0 12px; }
|
||||||
|
.vac-holiday-form, .vac-balance-form {
|
||||||
|
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px; margin-bottom: 12px; align-items: end;
|
||||||
|
}
|
||||||
|
.vac-holiday-form button, .vac-balance-form button { grid-column: 1 / -1; }
|
||||||
|
.vac-check { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||||
|
.vac-set-scroll { max-height: 420px; overflow: auto; }
|
||||||
|
.vac-set-note { font-size: 12px; margin-top: 8px; }
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/* 휴가 신청 폼 — 종류에 따른 필드 토글 + 예상 일수 미리보기.
|
||||||
|
서버(store.compute_days)가 공휴일 포함 정확히 재계산한다. 여기선 주말만 제외한 근사치. */
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
var form = document.getElementById("vac-form");
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
var type = document.getElementById("vac-type");
|
||||||
|
var start = document.getElementById("vac-start");
|
||||||
|
var startHalf = document.getElementById("vac-start-half");
|
||||||
|
var endField = document.getElementById("vac-end-field");
|
||||||
|
var end = document.getElementById("vac-end");
|
||||||
|
var endHalfField = document.getElementById("vac-end-half-field");
|
||||||
|
var endHalf = document.getElementById("vac-end-half");
|
||||||
|
var startHalfField = document.getElementById("vac-start-half-field");
|
||||||
|
var out = document.getElementById("vac-days-out");
|
||||||
|
|
||||||
|
function isHalfType(t) { return t === "오전반차" || t === "오후반차"; }
|
||||||
|
|
||||||
|
function parseDate(s) {
|
||||||
|
if (!s) return null;
|
||||||
|
var p = s.split("-");
|
||||||
|
if (p.length !== 3) return null;
|
||||||
|
return new Date(+p[0], +p[1] - 1, +p[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workingDays(sd, ed) {
|
||||||
|
var n = 0;
|
||||||
|
var d = new Date(sd.getTime());
|
||||||
|
while (d <= ed) {
|
||||||
|
var wd = d.getDay(); // 0=일, 6=토
|
||||||
|
if (wd !== 0 && wd !== 6) n++;
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorking(d) {
|
||||||
|
var wd = d.getDay();
|
||||||
|
return wd !== 0 && wd !== 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalc() {
|
||||||
|
var t = type.value;
|
||||||
|
var half = isHalfType(t);
|
||||||
|
|
||||||
|
// 반차 종류면 종료일/구분 숨김, 단일 0.5
|
||||||
|
endField.style.display = half ? "none" : "";
|
||||||
|
endHalfField.style.display = half ? "none" : "";
|
||||||
|
startHalfField.style.display = half ? "none" : "";
|
||||||
|
|
||||||
|
if (half) {
|
||||||
|
out.textContent = "0.5";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sd = parseDate(start.value);
|
||||||
|
var ed = parseDate(end.value) || sd;
|
||||||
|
if (!sd || !ed || ed < sd) { out.textContent = "—"; return; }
|
||||||
|
|
||||||
|
if (sd.getTime() === ed.getTime()) {
|
||||||
|
out.textContent = (startHalf.value === "am" || startHalf.value === "pm") ? "0.5" : "1";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var n = workingDays(sd, ed);
|
||||||
|
var total = n;
|
||||||
|
if ((startHalf.value === "am" || startHalf.value === "pm") && isWorking(sd)) total -= 0.5;
|
||||||
|
if ((endHalf.value === "am" || endHalf.value === "pm") && isWorking(ed)) total -= 0.5;
|
||||||
|
out.textContent = total > 0 ? String(total) : "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시작일 변경 시 종료일이 비었거나 더 빠르면 맞춰줌
|
||||||
|
start.addEventListener("change", function () {
|
||||||
|
if (!end.value || parseDate(end.value) < parseDate(start.value)) {
|
||||||
|
end.value = start.value;
|
||||||
|
}
|
||||||
|
recalc();
|
||||||
|
});
|
||||||
|
|
||||||
|
[type, end, startHalf, endHalf].forEach(function (el) {
|
||||||
|
if (el) el.addEventListener("change", recalc);
|
||||||
|
});
|
||||||
|
|
||||||
|
recalc();
|
||||||
|
})();
|
||||||