Compare commits
219 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 |
@@ -20,3 +20,55 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
|
|||||||
# 설정하면 PostgreSQL 사용, 미설정 시 DATA_DIR/expense.json 사용.
|
# 설정하면 PostgreSQL 사용, 미설정 시 DATA_DIR/expense.json 사용.
|
||||||
# DB/역할 생성: scripts/sql/expense_db_init.sql 참고.
|
# DB/역할 생성: scripts/sql/expense_db_init.sql 참고.
|
||||||
# EXPENSE_DB_URL=postgresql://expense_app:replace-me@postgres-db:5432/expense_db
|
# 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,6 +2,9 @@
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
|
# 런타임 데이터 (사용자 이메일/경비 등 PII — 커밋 금지. 운영은 DATA_DIR 볼륨)
|
||||||
|
app/data/
|
||||||
|
|
||||||
# 로컬 전용 스크립트 (자격증명 포함 가능)
|
# 로컬 전용 스크립트 (자격증명 포함 가능)
|
||||||
push-gitea.bat
|
push-gitea.bat
|
||||||
run-local.bat
|
run-local.bat
|
||||||
@@ -17,3 +20,6 @@ __pycache__/
|
|||||||
.idea/
|
.idea/
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
|
||||||
|
# dispatch 라벨 PDF 샘플(실제 고객 PII — 커밋 금지)
|
||||||
|
docs/samples/
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
|
|||||||
- CS관리
|
- CS관리
|
||||||
- 반품관리
|
- 반품관리
|
||||||
- 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등)
|
- 외부 쇼핑몰 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`.
|
상세는 `docs/PROJECT_OVERVIEW.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -13,9 +13,20 @@ from jinja2 import ChoiceLoader, FileSystemLoader
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from .modules.expense import build_expense_store
|
from .modules.cupang import build_cupang_store, build_itemcode_reader
|
||||||
|
from .modules.cupang import router as cupang_router
|
||||||
|
from .modules.dispatch import build_dispatch_store
|
||||||
|
from .modules.dispatch import router as dispatch_router
|
||||||
|
from .modules.expense import CategoryStore, build_expense_store
|
||||||
from .modules.expense import router as expense_router
|
from .modules.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 (
|
from .store import (
|
||||||
|
APPROVER_KEYS,
|
||||||
MODULE_KEYS,
|
MODULE_KEYS,
|
||||||
SUPER_ADMIN_EMAIL,
|
SUPER_ADMIN_EMAIL,
|
||||||
UserStore,
|
UserStore,
|
||||||
@@ -24,6 +35,20 @@ from .store import (
|
|||||||
is_admin,
|
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 도 동일 이름.
|
# OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름.
|
||||||
SESSION_COOKIE_DEFAULT = "session"
|
SESSION_COOKIE_DEFAULT = "session"
|
||||||
|
|
||||||
@@ -90,6 +115,11 @@ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="stat
|
|||||||
# 신규 모듈 추가 시 아래 리스트에 `BASE_DIR / "modules" / "<name>" / "templates"` 만 추가.
|
# 신규 모듈 추가 시 아래 리스트에 `BASE_DIR / "modules" / "<name>" / "templates"` 만 추가.
|
||||||
_MODULE_TEMPLATE_DIRS = [
|
_MODULE_TEMPLATE_DIRS = [
|
||||||
BASE_DIR / "modules" / "expense" / "templates",
|
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(
|
templates.env.loader = ChoiceLoader(
|
||||||
@@ -99,18 +129,55 @@ templates.env.loader = ChoiceLoader(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# dispatch 모듈 템플릿 필터: 배송사 로고 URL / 송장 조회 딥링크.
|
||||||
|
# {{ provider | courier_logo }} · {{ tracking | courier_track(provider) }}
|
||||||
|
from .modules.dispatch.store import ( # noqa: E402
|
||||||
|
courier_logo_url as _courier_logo_url,
|
||||||
|
)
|
||||||
|
from .modules.dispatch.store import ( # noqa: E402
|
||||||
|
courier_tracking_url as _courier_tracking_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
templates.env.filters["courier_logo"] = _courier_logo_url
|
||||||
|
templates.env.filters["courier_track"] = _courier_tracking_url
|
||||||
|
|
||||||
oauth = build_google_oauth()
|
oauth = build_google_oauth()
|
||||||
user_store = UserStore(DATA_DIR / "users.json")
|
user_store = UserStore(DATA_DIR / "users.json")
|
||||||
|
|
||||||
# 모듈별 데이터 저장소.
|
# 모듈별 데이터 저장소.
|
||||||
# EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일.
|
# EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일.
|
||||||
|
app.state.data_dir = DATA_DIR # 모듈에서 첨부 저장 경로 등으로 참조
|
||||||
app.state.expense_store = build_expense_store(
|
app.state.expense_store = build_expense_store(
|
||||||
dsn=env("EXPENSE_DB_URL") or None,
|
dsn=env("EXPENSE_DB_URL") or None,
|
||||||
json_path=DATA_DIR / "expense.json",
|
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(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:
|
||||||
@@ -233,14 +300,54 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
|
|||||||
"status": "ready",
|
"status": "ready",
|
||||||
"category": "관리",
|
"category": "관리",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "cupang",
|
||||||
|
"title": "쿠팡 밀크런",
|
||||||
|
"subtitle": "Coupang Milk-run",
|
||||||
|
"description": "쿠팡 밀크런 출고 일정·박스 계산·입고센터를 달력에서 관리합니다.",
|
||||||
|
"url": "/cupang/",
|
||||||
|
"health_url": "/cupang/health",
|
||||||
|
"status": "ready",
|
||||||
|
"category": "운영",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "vacation",
|
"key": "vacation",
|
||||||
"title": "휴가",
|
"title": "휴가",
|
||||||
"subtitle": "Vacation",
|
"subtitle": "Vacation",
|
||||||
"description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 확인합니다.",
|
"description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 달력에서 관리합니다.",
|
||||||
"url": "#",
|
"url": "/vacation/",
|
||||||
"health_url": None,
|
"health_url": "/vacation/health",
|
||||||
"status": "preparing",
|
"status": "ready",
|
||||||
|
"category": "관리",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "관리",
|
"category": "관리",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -258,6 +365,10 @@ def _icon_svg(name: str) -> str:
|
|||||||
"vacation": '<path d="M8 2v4"/><path d="M16 2v4"/><rect x="3" y="6" width="18" height="15" rx="2"/><path d="M3 11h18"/>',
|
"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"/>',
|
"corm": '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z"/>',
|
||||||
"order": '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>',
|
"order": '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>',
|
||||||
|
"cupang": '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/>',
|
||||||
|
"malaysia": '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
|
||||||
|
"dispatch": '<rect x="1" y="3" width="15" height="13"/><path d="M16 8h4l3 3v5h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/>',
|
||||||
|
"project": '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="13" x2="13" y2="13"/><line x1="8" y1="16" x2="11" y2="16"/>',
|
||||||
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
|
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
|
||||||
}
|
}
|
||||||
body = paths.get(name, paths["modules"])
|
body = paths.get(name, paths["modules"])
|
||||||
@@ -301,6 +412,9 @@ def build_erp_nav(
|
|||||||
"disabled_reason": "준비중" if m["status"] != "ready" else None,
|
"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:
|
for it in items:
|
||||||
it["active"] = it["key"] == active
|
it["active"] = it["key"] == active
|
||||||
return items
|
return items
|
||||||
@@ -459,6 +573,8 @@ async def admin_page(request: Request) -> HTMLResponse:
|
|||||||
"user": rec,
|
"user": rec,
|
||||||
"users": users,
|
"users": users,
|
||||||
"module_keys": list(MODULE_KEYS),
|
"module_keys": list(MODULE_KEYS),
|
||||||
|
"module_labels": MODULE_LABELS,
|
||||||
|
"approver_keys": list(APPROVER_KEYS),
|
||||||
"super_admin_email": SUPER_ADMIN_EMAIL,
|
"super_admin_email": SUPER_ADMIN_EMAIL,
|
||||||
"is_admin": True,
|
"is_admin": True,
|
||||||
},
|
},
|
||||||
@@ -471,11 +587,35 @@ class UpdatePermissionsBody(BaseModel):
|
|||||||
modules: dict[str, bool] | None = None
|
modules: dict[str, bool] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserBody(BaseModel):
|
||||||
|
email: str
|
||||||
|
name: str = ""
|
||||||
|
role: str = "user"
|
||||||
|
modules: dict[str, bool] | None = None
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/users")
|
@app.get("/api/users")
|
||||||
async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse:
|
async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse:
|
||||||
return JSONResponse({"users": user_store.list_all()})
|
return JSONResponse({"users": user_store.list_all()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/users")
|
||||||
|
async def api_create_user(
|
||||||
|
body: CreateUserBody,
|
||||||
|
_: dict[str, Any] = Depends(require_admin),
|
||||||
|
) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
rec = user_store.create_user(
|
||||||
|
email=body.email,
|
||||||
|
name=body.name,
|
||||||
|
role=body.role,
|
||||||
|
modules=body.modules,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return JSONResponse({"user": rec}, status_code=201)
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/users/{email}")
|
@app.put("/api/users/{email}")
|
||||||
async def api_update_user(
|
async def api_update_user(
|
||||||
email: str,
|
email: str,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -9,12 +9,15 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from .categories import DEFAULT_CATEGORIES, CategoryStore
|
||||||
from .router import router
|
from .router import router
|
||||||
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"router",
|
"router",
|
||||||
"ExpenseStore",
|
"ExpenseStore",
|
||||||
|
"CategoryStore",
|
||||||
|
"DEFAULT_CATEGORIES",
|
||||||
"CATEGORIES",
|
"CATEGORIES",
|
||||||
"METHODS",
|
"METHODS",
|
||||||
"STATUSES",
|
"STATUSES",
|
||||||
|
|||||||
@@ -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"])
|
||||||
@@ -18,29 +18,21 @@ from typing import Any
|
|||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
from psycopg_pool import ConnectionPool
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
from .store import CATEGORIES, METHODS, STATUSES
|
from app.timezone import KST
|
||||||
|
|
||||||
_DDL = """
|
from .store import APPROVED_STATUSES, CATEGORIES, METHODS, STATUSES
|
||||||
CREATE TABLE IF NOT EXISTS expense_items (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
owner TEXT NOT NULL,
|
|
||||||
spent_at DATE NOT NULL,
|
|
||||||
category TEXT NOT NULL,
|
|
||||||
method TEXT NOT NULL,
|
|
||||||
merchant TEXT NOT NULL DEFAULT '',
|
|
||||||
amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0),
|
|
||||||
memo TEXT NOT NULL DEFAULT '',
|
|
||||||
status TEXT NOT NULL DEFAULT '작성중',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_expense_owner_spent ON expense_items (owner, spent_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_expense_status ON expense_items (status);
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class ExpenseDBStore:
|
class ExpenseDBStore:
|
||||||
"""`ExpenseStore` 와 동일한 메서드 시그니처."""
|
"""`ExpenseStore` 와 동일한 메서드 시그니처.
|
||||||
|
|
||||||
|
스키마(테이블/인덱스/트리거)는 앱이 직접 만들지 않는다.
|
||||||
|
`scripts/sql/expense_db_init.sql` 과 `expense_db_002_*.sql` 을 통해
|
||||||
|
superuser 가 사전 적용한다. 앱 계정(expense_app)은 SELECT/INSERT/UPDATE/DELETE
|
||||||
|
권한만 받기 때문에 PostgreSQL 15+ 의 strict public-schema 정책과 충돌하지 않음.
|
||||||
|
|
||||||
|
연결 풀은 lazy open — 부팅 시점에 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||||
self._pool = ConnectionPool(
|
self._pool = ConnectionPool(
|
||||||
@@ -48,17 +40,13 @@ class ExpenseDBStore:
|
|||||||
min_size=min_size,
|
min_size=min_size,
|
||||||
max_size=max_size,
|
max_size=max_size,
|
||||||
kwargs={"row_factory": dict_row, "autocommit": True},
|
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||||
open=True,
|
open=False,
|
||||||
)
|
)
|
||||||
self._ensure_schema()
|
self._pool.open(wait=False)
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self._pool.close()
|
self._pool.close()
|
||||||
|
|
||||||
def _ensure_schema(self) -> None:
|
|
||||||
with self._pool.connection() as conn:
|
|
||||||
conn.execute(_DDL)
|
|
||||||
|
|
||||||
# ── 조회 ──
|
# ── 조회 ──
|
||||||
def list_for(self, email: str) -> list[dict[str, Any]]:
|
def list_for(self, email: str) -> list[dict[str, Any]]:
|
||||||
email = email.lower().strip()
|
email = email.lower().strip()
|
||||||
@@ -178,6 +166,307 @@ class ExpenseDBStore:
|
|||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
raise KeyError(item_id)
|
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]:
|
def summary_for(self, email: str) -> dict[str, Any]:
|
||||||
email = email.lower().strip()
|
email = email.lower().strip()
|
||||||
@@ -218,10 +507,10 @@ class ExpenseDBStore:
|
|||||||
out = dict(row)
|
out = dict(row)
|
||||||
if isinstance(out.get("spent_at"), date):
|
if isinstance(out.get("spent_at"), date):
|
||||||
out["spent_at"] = out["spent_at"].isoformat()
|
out["spent_at"] = out["spent_at"].isoformat()
|
||||||
for k in ("created_at", "updated_at"):
|
for k in ("created_at", "updated_at", "decided_at"):
|
||||||
v = out.get(k)
|
v = out.get(k)
|
||||||
if isinstance(v, datetime):
|
if isinstance(v, datetime):
|
||||||
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
|
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||||
out["amount"] = int(out.get("amount", 0))
|
out["amount"] = int(out.get("amount", 0))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -233,7 +522,8 @@ class ExpenseDBStore:
|
|||||||
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
|
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()
|
category = str(payload.get("category") or b.get("category") or "기타").strip()
|
||||||
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
||||||
b["category"] = category if category in CATEGORIES else "기타"
|
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
|
||||||
|
b["category"] = category or "기타"
|
||||||
b["method"] = method if method in METHODS else "법인카드"
|
b["method"] = method if method in METHODS else "법인카드"
|
||||||
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -13,17 +13,20 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from app.timezone import now_kst_iso
|
||||||
|
|
||||||
CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타")
|
CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타")
|
||||||
METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금")
|
METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금")
|
||||||
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료")
|
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료")
|
||||||
|
# 승인 완료(결재 승인 이후) 상태 — "승인완료" 집계/내보내기 대상.
|
||||||
|
APPROVED_STATUSES: tuple[str, ...] = ("승인", "정산완료")
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
return now_kst_iso()
|
||||||
|
|
||||||
|
|
||||||
class ExpenseStore:
|
class ExpenseStore:
|
||||||
@@ -148,7 +151,8 @@ class ExpenseStore:
|
|||||||
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
|
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()
|
category = str(payload.get("category") or b.get("category") or "기타").strip()
|
||||||
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
||||||
b["category"] = category if category in CATEGORIES else "기타"
|
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
|
||||||
|
b["category"] = category or "기타"
|
||||||
b["method"] = method if method in METHODS else "법인카드"
|
b["method"] = method if method in METHODS else "법인카드"
|
||||||
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="erp-ex-approved">
|
||||||
|
|
||||||
|
<!-- ── 상단 액션 + 월 선택 ── -->
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-ghost" href="/expense/">← 개인경비</a>
|
||||||
|
<form id="mon-form" method="get" action="/expense/approved" style="display:flex; gap:var(--sp-8); align-items:center; margin:0;">
|
||||||
|
<input type="month" name="month" value="{{ month }}" />
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
|
||||||
|
</form>
|
||||||
|
<a class="erp-btn erp-btn-primary" href="/expense/api/approved/attachments.zip?month={{ month }}">전부 다운로드(zip)</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 직원별 합계 ── -->
|
||||||
|
<div class="erp-card-block">
|
||||||
|
<div class="erp-card-block-head">
|
||||||
|
<h2>직원별 승인 금액 합계</h2>
|
||||||
|
<span class="erp-muted">{{ month }} · 총 {{ "{:,}".format(grand_total) }} 원</span>
|
||||||
|
</div>
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>이메일</th>
|
||||||
|
<th style="width:100px; text-align:right;">건수</th>
|
||||||
|
<th style="width:160px; text-align:right;">합계금액</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in owner_summary %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td class="erp-muted">{{ r.owner }}</td>
|
||||||
|
<td style="text-align:right;">{{ r.count }}</td>
|
||||||
|
<td style="text-align:right; font-variant-numeric: tabular-nums;">{{ "{:,}".format(r.total) }} 원</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4" class="erp-empty">해당 월 승인완료 항목이 없습니다.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% if owner_summary %}
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<th colspan="2" style="text-align:right;">합계</th>
|
||||||
|
<th style="text-align:right;">{{ count }}</th>
|
||||||
|
<th style="text-align:right;">{{ "{:,}".format(grand_total) }} 원</th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 승인완료 항목 목록 ── -->
|
||||||
|
<div class="erp-card-block">
|
||||||
|
<div class="erp-card-block-head">
|
||||||
|
<h2>승인완료 항목 ({{ count }}건)</h2>
|
||||||
|
<span class="erp-muted">전 직원 · 사용일 기준 {{ month }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table" id="appr-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:110px;">사용일</th>
|
||||||
|
<th style="width:120px;">이름</th>
|
||||||
|
<th style="width:90px;">분류</th>
|
||||||
|
<th style="width:100px;">수단</th>
|
||||||
|
<th>가맹점/메모</th>
|
||||||
|
<th style="width:120px; text-align:right;">금액</th>
|
||||||
|
<th style="width:90px;">상태</th>
|
||||||
|
<th style="width:60px; text-align:center;">첨부</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in items %}
|
||||||
|
<tr data-id="{{ it.id }}">
|
||||||
|
<td>{{ it.spent_at }}</td>
|
||||||
|
<td>{{ it.owner_name }}<div class="erp-row-sub">{{ it.owner }}</div></td>
|
||||||
|
<td>{{ it.category }}</td>
|
||||||
|
<td>{{ it.method }}</td>
|
||||||
|
<td>
|
||||||
|
<div>{{ it.merchant }}</div>
|
||||||
|
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right; font-variant-numeric: tabular-nums;">{{ "{:,}".format(it.amount) }} 원</td>
|
||||||
|
<td>
|
||||||
|
{% if it.status == '정산완료' %}
|
||||||
|
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="erp-badge erp-badge-inverse">{{ it.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center;">
|
||||||
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-view-att" title="첨부 보기">📎</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8" class="erp-empty">해당 월 승인완료 항목이 없습니다.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.erp-page-actions { display:flex; gap:var(--sp-8); margin-bottom:var(--sp-16); flex-wrap:wrap; align-items:center; }
|
||||||
|
.erp-ex-approved .erp-row-sub { font-size:12px; color:var(--color-text-muted, #888); }
|
||||||
|
#appr-table td { vertical-align: middle; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// 월 선택 변경 시 자동 조회
|
||||||
|
const monInput = document.querySelector('#mon-form input[name="month"]');
|
||||||
|
if (monInput) monInput.addEventListener("change", () => monInput.form.submit());
|
||||||
|
|
||||||
|
// 첨부 보기 (공용 뷰어)
|
||||||
|
const tbody = document.querySelector("#appr-table tbody");
|
||||||
|
if (tbody) {
|
||||||
|
tbody.addEventListener("click", (e) => {
|
||||||
|
const btn = e.target.closest("button.js-view-att");
|
||||||
|
if (!btn) return;
|
||||||
|
const tr = btn.closest("tr[data-id]");
|
||||||
|
const id = tr.dataset.id;
|
||||||
|
const who = tr.children[1].textContent.trim();
|
||||||
|
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${who}` });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,18 +3,25 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="erp-expense">
|
<section class="erp-expense">
|
||||||
|
|
||||||
|
<!-- ── 페이지 액션 ── -->
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
{% if is_approver %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/pending">
|
||||||
|
승인 대기 {% if pending_count %}<strong style="margin-left:6px;">{{ pending_count }}</strong>{% endif %}
|
||||||
|
</a>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/approved">승인완료</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=mine">엑셀(내 항목)</a>
|
||||||
|
{% if is_approver %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_approver %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/settings">⚙ 설정</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── 요약 카드 ── -->
|
<!-- ── 요약 카드 ── -->
|
||||||
<div class="erp-summary-grid">
|
<div class="erp-summary-grid">
|
||||||
<div class="erp-summary-card">
|
|
||||||
<span class="erp-summary-label">총 건수</span>
|
|
||||||
<strong class="erp-summary-value">{{ summary.count }} 건</strong>
|
|
||||||
</div>
|
|
||||||
<div class="erp-summary-card">
|
|
||||||
<span class="erp-summary-label">총 금액</span>
|
|
||||||
<strong class="erp-summary-value" id="ex-total-amount">
|
|
||||||
{{ "{:,}".format(summary.total) }} 원
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
{% for status in statuses %}
|
{% for status in statuses %}
|
||||||
<div class="erp-summary-card erp-summary-card--mini">
|
<div class="erp-summary-card erp-summary-card--mini">
|
||||||
<span class="erp-summary-label">{{ status }}</span>
|
<span class="erp-summary-label">{{ status }}</span>
|
||||||
@@ -25,40 +32,35 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 등록(좌) / 내역(우) 2단 ── -->
|
||||||
|
<div class="ex-two-col">
|
||||||
|
|
||||||
<!-- ── 입력 폼 ── -->
|
<!-- ── 입력 폼 ── -->
|
||||||
<div class="erp-card-block">
|
<div class="erp-card-block">
|
||||||
<div class="erp-card-block-head">
|
<div class="erp-card-block-head">
|
||||||
<h2>경비 등록</h2>
|
<h2>경비 등록</h2>
|
||||||
<span class="erp-muted">필수 항목을 입력하고 등록을 누르세요.</span>
|
<span class="erp-muted">필수 항목 입력 후 등록. 등록 후 항목별로 첨부/제출.</span>
|
||||||
</div>
|
</div>
|
||||||
<form id="ex-form" class="erp-form-grid">
|
<form id="ex-form" class="erp-form-grid">
|
||||||
<input type="hidden" name="id" />
|
<input type="hidden" name="id" />
|
||||||
<label class="erp-field">
|
<label class="erp-field"><span>사용일</span><input type="date" name="spent_at" required /></label>
|
||||||
<span>사용일</span>
|
<label class="erp-field"><span>분류</span>
|
||||||
<input type="date" name="spent_at" required />
|
|
||||||
</label>
|
|
||||||
<label class="erp-field">
|
|
||||||
<span>분류</span>
|
|
||||||
<select name="category" required>
|
<select name="category" required>
|
||||||
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
|
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="erp-field">
|
<label class="erp-field"><span>결제수단</span>
|
||||||
<span>결제수단</span>
|
|
||||||
<select name="method" required>
|
<select name="method" required>
|
||||||
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
|
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="erp-field">
|
<label class="erp-field"><span>금액</span>
|
||||||
<span>금액</span>
|
|
||||||
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
|
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
|
||||||
</label>
|
</label>
|
||||||
<label class="erp-field erp-field-wide">
|
<label class="erp-field erp-field-wide"><span>가맹점/사용처</span>
|
||||||
<span>가맹점/사용처</span>
|
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" required />
|
||||||
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" />
|
|
||||||
</label>
|
</label>
|
||||||
<label class="erp-field erp-field-wide">
|
<label class="erp-field erp-field-wide"><span>메모</span>
|
||||||
<span>메모</span>
|
|
||||||
<input type="text" name="memo" placeholder="비고" />
|
<input type="text" name="memo" placeholder="비고" />
|
||||||
</label>
|
</label>
|
||||||
<div class="erp-form-actions">
|
<div class="erp-form-actions">
|
||||||
@@ -72,62 +74,170 @@
|
|||||||
<div class="erp-card-block">
|
<div class="erp-card-block">
|
||||||
<div class="erp-card-block-head">
|
<div class="erp-card-block-head">
|
||||||
<h2>경비 내역</h2>
|
<h2>경비 내역</h2>
|
||||||
<span class="erp-muted" id="ex-count">{{ items | length }}건</span>
|
<div style="display: flex; align-items: center; gap: var(--sp-12); flex-wrap: wrap;">
|
||||||
|
<form method="get" action="/expense/" id="ex-month-form" style="display:flex; gap:var(--sp-8); align-items:center; margin:0;">
|
||||||
|
<input type="month" name="month" value="{{ month }}" />
|
||||||
|
</form>
|
||||||
|
<span class="ex-month-stat" id="ex-count">{{ items | length }}건</span>
|
||||||
|
<span class="ex-month-stat">합계 <strong>{{ "{:,}".format(month_total) }}</strong> 원</span>
|
||||||
|
{% if supports_workflow %}
|
||||||
|
<button id="ex-bulk-submit" class="erp-btn erp-btn-primary erp-btn-sm" disabled>
|
||||||
|
선택 항목 제출 (<span id="ex-bulk-count">0</span>)
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="erp-table-wrap">
|
<div class="erp-table-wrap">
|
||||||
<table class="erp-table" id="ex-table">
|
<table class="erp-table" id="ex-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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: 110px;">사용일</th>
|
||||||
<th style="width: 90px;">분류</th>
|
<th style="width: 90px;">분류</th>
|
||||||
<th style="width: 100px;">수단</th>
|
<th style="width: 100px;">수단</th>
|
||||||
<th>가맹점</th>
|
<th>가맹점</th>
|
||||||
<th style="width: 120px; text-align: right;">금액</th>
|
<th style="width: 120px;">금액</th>
|
||||||
<th style="width: 90px;">상태</th>
|
<th style="width: 90px;">상태</th>
|
||||||
<th style="width: 130px; text-align: right;">동작</th>
|
<th style="width: 240px;"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="ex-tbody">
|
<tbody id="ex-tbody">
|
||||||
{% for it in items %}
|
{% for it in items %}
|
||||||
<tr data-id="{{ it.id }}">
|
<tr data-id="{{ it.id }}" data-status="{{ it.status }}">
|
||||||
|
<td style="text-align: center;">
|
||||||
|
{% if it.status in ('작성중', '반려') and supports_workflow %}
|
||||||
|
<input type="checkbox" class="js-select" />
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ it.spent_at }}</td>
|
<td>{{ it.spent_at }}</td>
|
||||||
<td>{{ it.category }}</td>
|
<td>{{ it.category }}</td>
|
||||||
<td>{{ it.method }}</td>
|
<td>{{ it.method }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div>{{ it.merchant }}</div>
|
<div>{{ it.merchant }}</div>
|
||||||
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
{% 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>
|
||||||
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||||
{{ "{:,}".format(it.amount) }} 원
|
{{ "{:,}".format(it.amount) }} 원
|
||||||
</td>
|
</td>
|
||||||
<td><span class="erp-badge erp-badge-neutral">{{ it.status }}</span></td>
|
<td>
|
||||||
<td style="text-align: right;">
|
{% if it.status == '작성중' or it.status == '반려' %}
|
||||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
|
<span class="erp-badge erp-badge-outline">{{ it.status }}</span>
|
||||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
|
{% 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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr id="ex-empty"><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>
|
<tr id="ex-empty"><td colspan="8" class="erp-empty">등록된 경비가 없습니다.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /ex-two-col -->
|
||||||
|
|
||||||
</section>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
const supportsWorkflow = {{ 'true' if supports_workflow else 'false' }};
|
||||||
const form = document.getElementById("ex-form");
|
const form = document.getElementById("ex-form");
|
||||||
const submitBtn = document.getElementById("ex-submit");
|
const submitBtn = document.getElementById("ex-submit");
|
||||||
const resetBtn = document.getElementById("ex-reset");
|
const resetBtn = document.getElementById("ex-reset");
|
||||||
const tbody = document.getElementById("ex-tbody");
|
const tbody = document.getElementById("ex-tbody");
|
||||||
const countEl = document.getElementById("ex-count");
|
const selectAll = document.getElementById("ex-select-all");
|
||||||
const totalEl = document.getElementById("ex-total-amount");
|
const bulkBtn = document.getElementById("ex-bulk-submit");
|
||||||
|
const bulkCountEl = document.getElementById("ex-bulk-count");
|
||||||
const fmt = (n) => `${Number(n || 0).toLocaleString("ko-KR")} 원`;
|
|
||||||
|
|
||||||
function setEditing(id, data) {
|
function setEditing(id, data) {
|
||||||
form.id.value = id || "";
|
form.id.value = id || "";
|
||||||
@@ -143,11 +253,39 @@
|
|||||||
submitBtn.textContent = "등록";
|
submitBtn.textContent = "등록";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resetBtn.addEventListener("click", () => setEditing("", null));
|
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) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!validateRequired()) return;
|
||||||
const id = form.id.value.trim();
|
const id = form.id.value.trim();
|
||||||
const payload = {
|
const payload = {
|
||||||
spent_at: form.spent_at.value,
|
spent_at: form.spent_at.value,
|
||||||
@@ -165,74 +303,116 @@
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||||
await reload();
|
location.reload();
|
||||||
form.reset();
|
|
||||||
setEditing("", null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`저장 실패: ${err.message || 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) => {
|
tbody.addEventListener("click", async (e) => {
|
||||||
const btn = e.target.closest("button");
|
const btn = e.target.closest("button");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const tr = btn.closest("tr");
|
const tr = btn.closest("tr[data-id]");
|
||||||
const id = tr && tr.dataset.id;
|
if (!tr) return;
|
||||||
if (!id) return;
|
const id = tr.dataset.id;
|
||||||
if (btn.classList.contains("js-delete")) {
|
|
||||||
if (!confirm("이 항목을 삭제할까요?")) return;
|
if (btn.classList.contains("js-edit")) {
|
||||||
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
|
|
||||||
if (res.ok) reload(); else alert("삭제 실패");
|
|
||||||
} else if (btn.classList.contains("js-edit")) {
|
|
||||||
const res = await fetch(`/expense/api/items`);
|
const res = await fetch(`/expense/api/items`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const j = await res.json();
|
const { items } = await res.json();
|
||||||
const found = (j.items || []).find((x) => x.id === id);
|
const found = items.find((x) => x.id === id);
|
||||||
if (found) setEditing(id, found);
|
if (found) setEditing(id, found);
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
} else if (btn.classList.contains("js-delete")) {
|
||||||
|
if (!confirm("이 항목을 삭제할까요? 첨부도 함께 삭제됩니다.")) return;
|
||||||
|
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) location.reload(); else alert((await res.json()).detail || "삭제 실패");
|
||||||
|
} else if (btn.classList.contains("js-revert")) {
|
||||||
|
if (!confirm("작성중 상태로 되돌릴까요?")) return;
|
||||||
|
const res = await fetch(`/expense/api/items/${id}/revert`, { method: "POST" });
|
||||||
|
if (res.ok) location.reload(); else alert((await res.json()).detail || "취소 실패");
|
||||||
|
} else if (btn.classList.contains("js-view-att")) {
|
||||||
|
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${tr.children[1].textContent.trim()}` });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function reload() {
|
// 첨부 업로드
|
||||||
const res = await fetch(`/expense/api/items`);
|
tbody.addEventListener("change", async (e) => {
|
||||||
if (!res.ok) return;
|
const input = e.target.closest("input.js-upload");
|
||||||
const j = await res.json();
|
if (!input || !input.files.length) return;
|
||||||
const items = j.items || [];
|
const tr = input.closest("tr[data-id]");
|
||||||
const summary = j.summary || { count: 0, total: 0 };
|
const itemId = tr.dataset.id;
|
||||||
countEl.textContent = `${items.length}건`;
|
const fd = new FormData();
|
||||||
if (totalEl) totalEl.textContent = fmt(summary.total);
|
fd.append("file", input.files[0]);
|
||||||
|
fd.append("kind", input.dataset.kind || "other");
|
||||||
tbody.innerHTML = "";
|
try {
|
||||||
if (items.length === 0) {
|
const res = await fetch(`/expense/api/items/${itemId}/attachments`, {
|
||||||
tbody.innerHTML = `<tr><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>`;
|
method: "POST", body: fd,
|
||||||
return;
|
|
||||||
}
|
|
||||||
items
|
|
||||||
.sort((a, b) => (b.spent_at || "").localeCompare(a.spent_at || ""))
|
|
||||||
.forEach((it) => {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
tr.dataset.id = it.id;
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td>${it.spent_at || ""}</td>
|
|
||||||
<td>${it.category || ""}</td>
|
|
||||||
<td>${it.method || ""}</td>
|
|
||||||
<td><div>${it.merchant || ""}</div>${it.memo ? `<div class="erp-row-sub">${it.memo}</div>` : ""}</td>
|
|
||||||
<td style="text-align:right; font-variant-numeric: tabular-nums;">${fmt(it.amount)}</td>
|
|
||||||
<td><span class="erp-badge erp-badge-neutral">${it.status || "작성중"}</span></td>
|
|
||||||
<td style="text-align:right;">
|
|
||||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
|
|
||||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
|
|
||||||
</td>`;
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
});
|
});
|
||||||
}
|
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) {
|
if (!form.spent_at.value) {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
@@ -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: 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 };
|
||||||
|
})();
|
||||||
@@ -9,13 +9,15 @@
|
|||||||
--erp-topbar-h: 56px;
|
--erp-topbar-h: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 페이지 전체 컨테이너 ── */
|
/* ── 페이지 전체 컨테이너 ──
|
||||||
body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
뷰포트(예: 1883×938)에 고정 — 문서 전체 세로 스크롤 제거.
|
||||||
|
넘치는 콘텐츠는 .erp-page 내부에서만 처리한다(CORM/Order 는 별도 창이라 무관). */
|
||||||
|
body.erp-app-body { background: var(--color-canvas-white); height: 100vh; overflow: hidden; }
|
||||||
|
|
||||||
.erp-app {
|
.erp-app {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--erp-sidebar-w) 1fr;
|
grid-template-columns: var(--erp-sidebar-w) 1fr;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
transition: grid-template-columns .18s ease;
|
transition: grid-template-columns .18s ease;
|
||||||
}
|
}
|
||||||
.erp-app:has(.erp-sidebar[data-collapsed="true"]) {
|
.erp-app:has(.erp-sidebar[data-collapsed="true"]) {
|
||||||
@@ -66,7 +68,7 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.erp-sidebar-group {
|
.erp-sidebar-group {
|
||||||
font-size: 11px; font-weight: 600;
|
font-size: 12px; font-weight: 600;
|
||||||
color: var(--color-midtone-gray);
|
color: var(--color-midtone-gray);
|
||||||
text-transform: uppercase; letter-spacing: 0.06em;
|
text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
padding: var(--sp-12) var(--sp-12) var(--sp-6);
|
padding: var(--sp-12) var(--sp-12) var(--sp-6);
|
||||||
@@ -74,18 +76,19 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
|||||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-group { opacity: 0; height: 0; padding: 0; pointer-events: none; }
|
.erp-sidebar[data-collapsed="true"] .erp-sidebar-group { opacity: 0; height: 0; padding: 0; pointer-events: none; }
|
||||||
|
|
||||||
.erp-sidebar-item {
|
.erp-sidebar-item {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
padding: 8px var(--sp-12);
|
padding: 11px var(--sp-12);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: var(--color-rich-black);
|
color: var(--color-rich-black);
|
||||||
font-size: 14px; font-weight: 500;
|
font-size: 16px; font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .12s ease, color .12s ease;
|
transition: background .12s ease, color .12s ease;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.erp-sidebar-item:hover:not(.is-disabled) { background: var(--color-ghost-gray); }
|
/* 실행중(is-active) 메뉴는 hover 시 변화 없음. 그 외 메뉴만 진한 회색. */
|
||||||
|
.erp-sidebar-item:hover:not(.is-disabled):not(.is-active) { background: #d9d9d9; }
|
||||||
.erp-sidebar-item.is-active {
|
.erp-sidebar-item.is-active {
|
||||||
background: var(--color-deep-black);
|
background: var(--color-deep-black);
|
||||||
color: var(--color-canvas-white);
|
color: var(--color-canvas-white);
|
||||||
@@ -95,10 +98,11 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.erp-sidebar-icon {
|
.erp-sidebar-icon {
|
||||||
width: 18px; height: 18px;
|
width: 22px; height: 22px;
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.erp-sidebar-icon svg { width: 21px; height: 21px; }
|
||||||
.erp-sidebar-label { flex: 1; }
|
.erp-sidebar-label { flex: 1; }
|
||||||
.erp-sidebar-badge {
|
.erp-sidebar-badge {
|
||||||
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
|
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
|
||||||
@@ -110,12 +114,19 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
|||||||
}
|
}
|
||||||
.erp-sidebar-ext { color: var(--color-midtone-gray); display: inline-flex; }
|
.erp-sidebar-ext { color: var(--color-midtone-gray); display: inline-flex; }
|
||||||
|
|
||||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-label,
|
|
||||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-badge,
|
.erp-sidebar[data-collapsed="true"] .erp-sidebar-badge,
|
||||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-ext {
|
.erp-sidebar[data-collapsed="true"] .erp-sidebar-ext {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-item { justify-content: center; padding: 8px 0; }
|
/* 접힌 상태: 아이콘 위 / 프로그램 이름 작은 글씨 아래 */
|
||||||
|
.erp-sidebar[data-collapsed="true"] .erp-sidebar-item {
|
||||||
|
flex-direction: column; gap: 3px; padding: 8px 2px;
|
||||||
|
justify-content: center; text-align: center;
|
||||||
|
}
|
||||||
|
.erp-sidebar[data-collapsed="true"] .erp-sidebar-label {
|
||||||
|
flex: none; font-size: 10px; line-height: 1.1; font-weight: 500;
|
||||||
|
white-space: normal; word-break: keep-all;
|
||||||
|
}
|
||||||
|
|
||||||
.erp-sidebar-foot {
|
.erp-sidebar-foot {
|
||||||
padding: var(--sp-8);
|
padding: var(--sp-8);
|
||||||
@@ -125,10 +136,10 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── 콘텐츠 영역 ── */
|
/* ── 콘텐츠 영역 ── */
|
||||||
.erp-content { display: flex; flex-direction: column; min-width: 0; }
|
.erp-content { display: flex; flex-direction: column; min-width: 0; height: 100vh; min-height: 0; }
|
||||||
|
|
||||||
.erp-topbar {
|
.erp-topbar {
|
||||||
height: var(--erp-topbar-h);
|
height: var(--erp-topbar-h); flex-shrink: 0;
|
||||||
position: sticky; top: 0; z-index: 30;
|
position: sticky; top: 0; z-index: 30;
|
||||||
background: var(--color-canvas-white);
|
background: var(--color-canvas-white);
|
||||||
border-bottom: 1px solid var(--color-subtle-ash);
|
border-bottom: 1px solid var(--color-subtle-ash);
|
||||||
@@ -156,8 +167,14 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
|||||||
|
|
||||||
.erp-page {
|
.erp-page {
|
||||||
padding: var(--sp-24);
|
padding: var(--sp-24);
|
||||||
max-width: 1280px; width: 100%; margin: 0 auto;
|
max-width: 1550px; width: 100%; margin: 0 auto;
|
||||||
|
flex: 1 1 auto; min-height: 0;
|
||||||
|
overflow-y: auto; /* 넘치면 페이지 영역만 스크롤(문서 전체 X) */
|
||||||
|
display: flex; flex-direction: column;
|
||||||
}
|
}
|
||||||
|
/* 달력 등 "한 화면 채움" 페이지: 첫 섹션이 남은 높이를 모두 차지 */
|
||||||
|
.erp-page > .vac,
|
||||||
|
.erp-page > .cpg { flex: 1 1 auto; min-height: 0; }
|
||||||
|
|
||||||
/* ── 홈 대시보드 타일 ── */
|
/* ── 홈 대시보드 타일 ── */
|
||||||
.erp-hero { margin: var(--sp-8) 0 var(--sp-24); }
|
.erp-hero { margin: var(--sp-8) 0 var(--sp-24); }
|
||||||
@@ -259,7 +276,7 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
|||||||
.erp-table tbody td {
|
.erp-table tbody td {
|
||||||
padding: 10px var(--sp-16);
|
padding: 10px var(--sp-16);
|
||||||
border-bottom: 1px solid var(--color-subtle-ash);
|
border-bottom: 1px solid var(--color-subtle-ash);
|
||||||
vertical-align: top;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.erp-table tbody tr:last-child td { border-bottom: 0; }
|
.erp-table tbody tr:last-child td { border-bottom: 0; }
|
||||||
.erp-table tbody tr:hover { background: var(--color-ghost-gray); }
|
.erp-table tbody tr:hover { background: var(--color-ghost-gray); }
|
||||||
@@ -268,16 +285,23 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
|||||||
|
|
||||||
/* ── 반응형 ── */
|
/* ── 반응형 ── */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.erp-app { grid-template-columns: 0 1fr; }
|
/* 모바일: grid 자체 해제(데스크탑 collapsed :has 규칙과의 특이도 싸움 회피).
|
||||||
|
사이드바는 흐름 밖 떠있는 오버레이, 본문은 풀폭 block. */
|
||||||
|
body.erp-app-body { height: auto; overflow: auto; }
|
||||||
|
.erp-app,
|
||||||
|
.erp-app:has(.erp-sidebar[data-collapsed="true"]) { display: block; }
|
||||||
.erp-sidebar {
|
.erp-sidebar {
|
||||||
position: fixed; left: 0; top: 0; height: 100vh;
|
position: fixed; left: 0; top: 0; height: 100vh; z-index: 1000;
|
||||||
width: var(--erp-sidebar-w);
|
width: var(--erp-sidebar-w);
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
transition: transform .2s ease;
|
transition: transform .2s ease;
|
||||||
box-shadow: 0 0 0 1px var(--color-subtle-ash);
|
box-shadow: 0 0 0 1px var(--color-subtle-ash);
|
||||||
}
|
}
|
||||||
|
/* 모바일선 데스크탑 접힘 무시 — 메뉴 열면 풀폭으로 */
|
||||||
|
.erp-sidebar[data-collapsed="true"] { width: var(--erp-sidebar-w); }
|
||||||
.erp-sidebar.is-open { transform: translateX(0); }
|
.erp-sidebar.is-open { transform: translateX(0); }
|
||||||
.erp-sidebar-mobile-toggle { display: inline-flex; }
|
.erp-sidebar-mobile-toggle { display: inline-flex; }
|
||||||
|
.erp-content { width: 100%; min-width: 0; }
|
||||||
.erp-form-grid { grid-template-columns: 1fr 1fr; }
|
.erp-form-grid { grid-template-columns: 1fr 1fr; }
|
||||||
.erp-field-wide { grid-column: span 2; }
|
.erp-field-wide { grid-column: span 2; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,14 @@ body.erp-body {
|
|||||||
background: var(--color-rich-black);
|
background: var(--color-rich-black);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 검정 배경 요소는 글자 무조건 흰색.
|
||||||
|
<a> 버튼/배지의 a:link/a:visited(특이도 0,1,1)가 클래스 색을 덮어쓰는 문제 차단. */
|
||||||
|
a.erp-btn-primary, a.erp-btn-primary:link, a.erp-btn-primary:visited, a.erp-btn-primary:hover,
|
||||||
|
.erp-badge-inverse, a.erp-badge-inverse:link, a.erp-badge-inverse:visited,
|
||||||
|
.erp-sidebar-item.is-active, a.erp-sidebar-item.is-active:link, a.erp-sidebar-item.is-active:visited {
|
||||||
|
color: var(--color-canvas-white) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.erp-btn-ghost {
|
.erp-btn-ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-rich-black);
|
color: var(--color-rich-black);
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/* Material Symbols Outlined — self-host (구글 CDN 대체).
|
||||||
|
원본: https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined
|
||||||
|
폰트 파일은 같은 폴더 material-symbols-outlined.woff2. */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Material Symbols Outlined';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url(material-symbols-outlined.woff2) format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-family: 'Material Symbols Outlined';
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
word-wrap: normal;
|
||||||
|
direction: ltr;
|
||||||
|
-webkit-font-feature-settings: 'liga';
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
}
|
||||||
@@ -10,19 +10,37 @@ import json
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from .timezone import now_kst_iso
|
||||||
|
|
||||||
# 슈퍼 관리자 — 강등/삭제 불가
|
# 슈퍼 관리자 — 강등/삭제 불가
|
||||||
SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr"
|
SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr"
|
||||||
|
|
||||||
# 시스템에서 지원하는 모듈 키 — 신규 추가시 여기 + 메뉴/템플릿 동시 갱신
|
# 시스템에서 지원하는 권한 키.
|
||||||
MODULE_KEYS: tuple[str, ...] = ("corm", "order", "expense", "vacation")
|
# - 접근권한: 모듈 페이지 진입 허용
|
||||||
|
# - 승인자: 결재 워크플로에서 승인/반려 가능
|
||||||
|
# 신규 추가 시 여기 + admin.html MODULE_LABELS + 라우터 검사 동시 갱신.
|
||||||
|
MODULE_KEYS: tuple[str, ...] = (
|
||||||
|
"corm",
|
||||||
|
"order",
|
||||||
|
"expense",
|
||||||
|
"vacation",
|
||||||
|
"cupang",
|
||||||
|
"malaysia",
|
||||||
|
"dispatch",
|
||||||
|
"project",
|
||||||
|
"expense_approver",
|
||||||
|
"vacation_approver",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 승인자 권한 키 — 결재 워크플로에서 별도 검사할 때 사용
|
||||||
|
APPROVER_KEYS: tuple[str, ...] = ("expense_approver", "vacation_approver")
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
return now_kst_iso()
|
||||||
|
|
||||||
|
|
||||||
class UserStore:
|
class UserStore:
|
||||||
@@ -64,14 +82,14 @@ class UserStore:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_modules(modules: dict[str, Any] | None, *, is_admin: bool) -> dict[str, bool]:
|
def _normalize_modules(modules: dict[str, Any] | None, *, is_admin: bool) -> dict[str, bool]:
|
||||||
|
# 신규(modules 미지정) 사용자는 역할 기본값(admin=전체ON / user=전체OFF)으로 시작.
|
||||||
|
# 기존 값이 있으면 그대로 존중한다 — admin 이라도 슈퍼관리자가 개별 모듈을
|
||||||
|
# 끌 수 있다(예전엔 admin 을 무조건 전체 ON 으로 덮어썼다).
|
||||||
base = UserStore._default_modules(is_admin)
|
base = UserStore._default_modules(is_admin)
|
||||||
if isinstance(modules, dict):
|
if isinstance(modules, dict):
|
||||||
for key in MODULE_KEYS:
|
for key in MODULE_KEYS:
|
||||||
if key in modules:
|
if key in modules:
|
||||||
base[key] = bool(modules[key])
|
base[key] = bool(modules[key])
|
||||||
if is_admin:
|
|
||||||
for key in MODULE_KEYS:
|
|
||||||
base[key] = True
|
|
||||||
return base
|
return base
|
||||||
|
|
||||||
def get(self, email: str) -> dict[str, Any] | None:
|
def get(self, email: str) -> dict[str, Any] | None:
|
||||||
@@ -88,6 +106,42 @@ class UserStore:
|
|||||||
data = self._read()
|
data = self._read()
|
||||||
return [self._enrich(email, rec) for email, rec in data["users"].items()]
|
return [self._enrich(email, rec) for email, rec in data["users"].items()]
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
name: str = "",
|
||||||
|
role: str = "user",
|
||||||
|
modules: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""관리자가 이메일만으로 신규 사용자 등록. 로그인 전이라도 권한 부여 가능."""
|
||||||
|
email = email.lower().strip()
|
||||||
|
if not email or "@" not in email:
|
||||||
|
raise ValueError("올바른 이메일 형식이 아닙니다.")
|
||||||
|
if role not in ("admin", "user"):
|
||||||
|
raise ValueError(f"role 값이 잘못되었습니다: {role}")
|
||||||
|
is_super = email == SUPER_ADMIN_EMAIL
|
||||||
|
with self._lock:
|
||||||
|
data = self._read()
|
||||||
|
if email in data["users"]:
|
||||||
|
raise ValueError(f"이미 등록된 사용자입니다: {email}")
|
||||||
|
now = _now_iso()
|
||||||
|
final_role = "admin" if is_super else role
|
||||||
|
rec = {
|
||||||
|
"email": email,
|
||||||
|
"name": name or email.split("@")[0],
|
||||||
|
"picture": "",
|
||||||
|
"role": final_role,
|
||||||
|
"modules": self._normalize_modules(
|
||||||
|
modules, is_admin=(final_role == "admin")
|
||||||
|
),
|
||||||
|
"created_at": now,
|
||||||
|
"last_login": "",
|
||||||
|
}
|
||||||
|
data["users"][email] = rec
|
||||||
|
self._write_atomic(data)
|
||||||
|
return self._enrich(email, rec)
|
||||||
|
|
||||||
def upsert_login(self, *, email: str, name: str, picture: str) -> dict[str, Any]:
|
def upsert_login(self, *, email: str, name: str, picture: str) -> dict[str, Any]:
|
||||||
"""로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신."""
|
"""로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신."""
|
||||||
email = email.lower().strip()
|
email = email.lower().strip()
|
||||||
@@ -176,10 +230,25 @@ def is_admin(user_rec: dict[str, Any] | None) -> bool:
|
|||||||
def has_module(user_rec: dict[str, Any] | None, module: str) -> bool:
|
def has_module(user_rec: dict[str, Any] | None, module: str) -> bool:
|
||||||
if not user_rec:
|
if not user_rec:
|
||||||
return False
|
return False
|
||||||
if is_admin(user_rec):
|
# 슈퍼관리자는 항상 전체 접근. 일반 admin 은 칩(modules) 상태를 따른다 —
|
||||||
|
# 슈퍼관리자가 admin 의 개별 프로그램 접근을 제어할 수 있도록.
|
||||||
|
if user_rec.get("is_super_admin"):
|
||||||
return True
|
return True
|
||||||
mods = user_rec.get("modules") or {}
|
mods = user_rec.get("modules") or {}
|
||||||
return bool(mods.get(module))
|
if mods.get(module):
|
||||||
|
return True
|
||||||
|
# 승인자(<module>_approver) 권한이 있으면 해당 모듈 접근도 허용 —
|
||||||
|
# 본인 경비 등록/제출도 가능해야 하므로.
|
||||||
|
if module in APPROVER_KEYS:
|
||||||
|
return False
|
||||||
|
if mods.get(f"{module}_approver"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_approver(user_rec: dict[str, Any] | None, kind: str) -> bool:
|
||||||
|
"""결재 승인자 권한 검사. kind 예: 'expense', 'vacation'."""
|
||||||
|
return has_module(user_rec, f"{kind}_approver")
|
||||||
|
|
||||||
|
|
||||||
def allowed_modules(user_rec: dict[str, Any] | None) -> set[str]:
|
def allowed_modules(user_rec: dict[str, Any] | None) -> set[str]:
|
||||||
|
|||||||
@@ -5,21 +5,63 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>사용자/권한 관리 — DBX ERP</title>
|
<title>사용자/권한 관리 — DBX ERP</title>
|
||||||
<link rel="stylesheet" href="/static/erp.css" />
|
<link rel="stylesheet" href="/static/erp.css" />
|
||||||
|
<style>
|
||||||
|
.erp-admin-toolbar {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-12);
|
||||||
|
margin: var(--sp-16) 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.erp-admin-toolbar .erp-input { flex: 0 1 280px; }
|
||||||
|
|
||||||
|
.erp-add-form {
|
||||||
|
display: flex; gap: var(--sp-8); align-items: center;
|
||||||
|
margin-left: auto; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.erp-add-form input, .erp-add-form select {
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
padding: 6px 10px; font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-mod-group { display: inline-block; margin-left: 6px; padding: 2px 6px; border-radius: 6px;
|
||||||
|
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
|
||||||
|
font-size: 10px; font-weight: 500; vertical-align: middle; }
|
||||||
|
|
||||||
|
/* 권한 칩 — 모듈 수가 늘어도 줄바꿈되어 가로로 안 넓어짐 */
|
||||||
|
.erp-perm-cell { padding: 8px 6px; }
|
||||||
|
.erp-perm-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
margin: 3px; padding: 4px 11px; border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-subtle-ash); background: #fff;
|
||||||
|
color: var(--color-midtone-gray); font-size: 12px; font-weight: 500;
|
||||||
|
font-family: inherit; cursor: pointer; user-select: none;
|
||||||
|
transition: background .1s, color .1s, border-color .1s;
|
||||||
|
}
|
||||||
|
.erp-perm-chip:hover { border-color: var(--color-midtone-gray); }
|
||||||
|
.erp-perm-chip.is-on {
|
||||||
|
background: var(--color-rich-black); color: #fff; border-color: var(--color-rich-black);
|
||||||
|
}
|
||||||
|
/* 승인 권한 칩 — 켜지면 파랑 강조로 구분 */
|
||||||
|
.erp-perm-chip.is-approver.is-on {
|
||||||
|
background: #1d4ed8; border-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.erp-perm-chip[data-locked="true"] { opacity: .5; cursor: not-allowed; }
|
||||||
|
.erp-perm-chip[data-locked="true"]:hover { border-color: var(--color-subtle-ash); }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="erp-body">
|
<body class="erp-body">
|
||||||
|
|
||||||
<div class="erp-shell">
|
<div class="erp-shell">
|
||||||
|
|
||||||
<!-- ── 상단 네비게이션 ── -->
|
|
||||||
<nav class="erp-nav">
|
<nav class="erp-nav">
|
||||||
<div class="erp-nav-left">
|
<div class="erp-nav-left">
|
||||||
<a href="/" class="erp-brand">
|
<a href="/" class="erp-brand">
|
||||||
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
|
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
|
||||||
<span class="erp-brand-divider"></span>
|
<span class="erp-brand-divider"></span>
|
||||||
<span class="erp-brand-system">ERP 시스템</span>
|
<span class="erp-brand-system">DBX ERP System</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="erp-nav-right">
|
<div class="erp-nav-right">
|
||||||
<a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a>
|
<a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a>
|
||||||
<div class="erp-user-chip">
|
<div class="erp-user-chip">
|
||||||
@@ -37,39 +79,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="erp-main" style="max-width: 1100px;">
|
<main class="erp-main" style="max-width: 1280px;">
|
||||||
|
|
||||||
<h1 class="erp-greeting">사용자 / 권한 관리</h1>
|
<h1 class="erp-greeting">사용자 / 권한 관리</h1>
|
||||||
<p class="erp-greeting-sub">
|
<p class="erp-greeting-sub">
|
||||||
회사 계정으로 로그인한 임직원의 모듈 접근 권한을 관리합니다.
|
이메일만으로 사용자 등록 가능 (회사 도메인 자동 검사).
|
||||||
토글을 변경한 뒤 <strong>저장</strong> 버튼을 누르세요.
|
토글을 변경한 뒤 <strong>저장</strong>을 누르세요.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- 이메일 등록 폼 + 검색 -->
|
||||||
<div class="erp-admin-toolbar">
|
<div class="erp-admin-toolbar">
|
||||||
<input type="search"
|
<input type="search" id="user-search" class="erp-input"
|
||||||
id="user-search"
|
placeholder="이메일/이름 검색…" style="padding: 6px 10px;" />
|
||||||
class="erp-input"
|
|
||||||
placeholder="이메일 또는 이름으로 검색…" />
|
|
||||||
<span class="erp-section-meta" id="user-count">총 {{ users | length }}명</span>
|
<span class="erp-section-meta" id="user-count">총 {{ users | length }}명</span>
|
||||||
|
|
||||||
|
<form id="add-user-form" class="erp-add-form">
|
||||||
|
<input type="email" name="email" required placeholder="email@dbxcorp.co.kr" style="width: 220px;" />
|
||||||
|
<input type="text" name="name" placeholder="이름(선택)" style="width: 120px;" />
|
||||||
|
<select name="role">
|
||||||
|
<option value="user">user</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary">사용자 추가</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="erp-table-wrap">
|
<div class="erp-table-wrap">
|
||||||
<table class="erp-table" id="users-table">
|
<table class="erp-table" id="users-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 28%;">사용자</th>
|
<th style="width: 200px;">사용자</th>
|
||||||
<th style="width: 11%;">역할</th>
|
<th style="width: 80px;">역할</th>
|
||||||
{% for key in module_keys %}
|
<th>접근 권한 <span class="erp-mod-group">클릭하여 토글</span></th>
|
||||||
<th style="text-align:center; width: 9%;">
|
<th style="width: 110px;">최근 로그인</th>
|
||||||
{% if key == 'corm' %}CORM
|
<th style="width: 130px; text-align: right;">동작</th>
|
||||||
{% elif key == 'order' %}Order
|
|
||||||
{% elif key == 'expense' %}개인경비
|
|
||||||
{% elif key == 'vacation' %}휴가
|
|
||||||
{% else %}{{ key }}{% endif %}
|
|
||||||
</th>
|
|
||||||
{% endfor %}
|
|
||||||
<th style="width: 13%;">최근 로그인</th>
|
|
||||||
<th style="width: 12%; text-align: right;">동작</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -101,24 +144,29 @@
|
|||||||
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
|
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
{% for key in module_keys %}
|
<td class="erp-perm-cell">
|
||||||
<td style="text-align:center;">
|
{% for key in module_keys %}
|
||||||
<label class="erp-switch">
|
<button type="button"
|
||||||
<input type="checkbox"
|
class="erp-perm-chip
|
||||||
data-field="module"
|
{% if u.modules[key] %}is-on{% endif %}
|
||||||
data-module="{{ key }}"
|
{% if key in approver_keys %}is-approver{% endif %}"
|
||||||
{% if u.modules[key] %}checked{% endif %}
|
data-module="{{ key }}"
|
||||||
{% if u.is_super_admin or u.role == 'admin' %}disabled{% endif %} />
|
aria-pressed="{{ 'true' if u.modules[key] else 'false' }}"
|
||||||
<span class="erp-switch-slider"></span>
|
{% if u.is_super_admin %}data-locked="true"{% endif %}>
|
||||||
</label>
|
{{ module_labels.get(key, key) }}{% if key in approver_keys %} 승인{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
|
||||||
<td style="font-size:12px; color: var(--color-midtone-gray);">
|
<td style="font-size:12px; color: var(--color-midtone-gray);">
|
||||||
{{ (u.last_login or '')[:16].replace('T', ' ') }}
|
{{ (u.last_login or '미접속')[:16].replace('T', ' ') }}
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: right;">
|
<td style="text-align: right;">
|
||||||
<button class="erp-btn erp-btn-primary" data-action="save"
|
<button class="erp-btn erp-btn-primary" data-action="save"
|
||||||
{% if u.is_super_admin %}disabled{% endif %}>저장</button>
|
{% if u.is_super_admin %}disabled{% endif %}>저장</button>
|
||||||
|
{% if not u.is_super_admin %}
|
||||||
|
<button class="erp-btn erp-btn-outline" data-action="delete"
|
||||||
|
style="padding: 4px 8px; font-size: 12px;">삭제</button>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -127,8 +175,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="erp-section-meta" style="margin-top: var(--sp-16);">
|
<p class="erp-section-meta" style="margin-top: var(--sp-16);">
|
||||||
※ <strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자이므로 권한을 변경할 수 없습니다.
|
※ <strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자, 권한 변경/삭제 불가(항상 전체 접근).
|
||||||
관리자(admin) 역할로 지정된 사용자에게는 모든 모듈 권한이 자동 부여됩니다.
|
admin 역할은 기본 전체 ON 이지만 슈퍼관리자가 개별 프로그램 접근을 끌 수 있다.
|
||||||
|
<code>expense_approver</code> / <code>vacation_approver</code> 는 결재 승인 권한.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
@@ -141,6 +190,7 @@
|
|||||||
const searchInput = document.getElementById("user-search");
|
const searchInput = document.getElementById("user-search");
|
||||||
const userCountEl = document.getElementById("user-count");
|
const userCountEl = document.getElementById("user-count");
|
||||||
const toastEl = document.getElementById("toast");
|
const toastEl = document.getElementById("toast");
|
||||||
|
const addForm = document.getElementById("add-user-form");
|
||||||
|
|
||||||
function markDirty(tr) { tr.setAttribute("data-dirty", "true"); }
|
function markDirty(tr) { tr.setAttribute("data-dirty", "true"); }
|
||||||
function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); }
|
function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); }
|
||||||
@@ -150,22 +200,25 @@
|
|||||||
toastEl.dataset.type = type || "info";
|
toastEl.dataset.type = type || "info";
|
||||||
toastEl.dataset.visible = "true";
|
toastEl.dataset.visible = "true";
|
||||||
clearTimeout(showToast._t);
|
clearTimeout(showToast._t);
|
||||||
showToast._t = setTimeout(() => {
|
showToast._t = setTimeout(() => { toastEl.dataset.visible = "false"; }, 2400);
|
||||||
toastEl.dataset.visible = "false";
|
|
||||||
}, 2400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 역할 변경시 모듈 토글 활성/비활성 갱신
|
function syncPermChips(tr) {
|
||||||
function syncModuleSwitches(tr) {
|
// 슈퍼관리자만 전체 ON + 잠금. 일반 admin 은 슈퍼관리자가 개별 제어 가능.
|
||||||
|
// 역할을 admin 으로 바꾸면 편의상 전체 ON(잠금 아님 — 다시 끌 수 있음).
|
||||||
const isSuper = tr.dataset.super === "true";
|
const isSuper = tr.dataset.super === "true";
|
||||||
const role = tr.querySelector("select[data-field='role']").value;
|
const adminRole = tr.querySelector("select[data-field='role']").value === "admin";
|
||||||
const adminRole = role === "admin";
|
tr.querySelectorAll(".erp-perm-chip").forEach((chip) => {
|
||||||
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
|
if (isSuper) {
|
||||||
if (isSuper || adminRole) {
|
chip.classList.add("is-on");
|
||||||
cb.checked = true;
|
chip.setAttribute("aria-pressed", "true");
|
||||||
cb.disabled = true;
|
chip.setAttribute("data-locked", "true");
|
||||||
} else {
|
} else {
|
||||||
cb.disabled = false;
|
chip.removeAttribute("data-locked");
|
||||||
|
if (adminRole) {
|
||||||
|
chip.classList.add("is-on");
|
||||||
|
chip.setAttribute("aria-pressed", "true");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -173,47 +226,96 @@
|
|||||||
tbody.addEventListener("change", (e) => {
|
tbody.addEventListener("change", (e) => {
|
||||||
const tr = e.target.closest("tr");
|
const tr = e.target.closest("tr");
|
||||||
if (!tr) return;
|
if (!tr) return;
|
||||||
if (e.target.dataset.field === "role") {
|
if (e.target.dataset.field === "role") syncPermChips(tr);
|
||||||
syncModuleSwitches(tr);
|
|
||||||
}
|
|
||||||
markDirty(tr);
|
markDirty(tr);
|
||||||
});
|
});
|
||||||
|
|
||||||
tbody.addEventListener("click", async (e) => {
|
tbody.addEventListener("click", async (e) => {
|
||||||
const btn = e.target.closest("button[data-action='save']");
|
// 권한 칩 토글 (잠금 상태 제외)
|
||||||
|
const chip = e.target.closest(".erp-perm-chip");
|
||||||
|
if (chip) {
|
||||||
|
if (chip.getAttribute("data-locked") === "true") return;
|
||||||
|
const on = chip.classList.toggle("is-on");
|
||||||
|
chip.setAttribute("aria-pressed", on ? "true" : "false");
|
||||||
|
markDirty(chip.closest("tr"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = e.target.closest("button[data-action]");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const tr = btn.closest("tr");
|
const tr = btn.closest("tr");
|
||||||
const email = tr.dataset.email;
|
const email = tr.dataset.email;
|
||||||
const role = tr.querySelector("select[data-field='role']").value;
|
|
||||||
const modules = {};
|
|
||||||
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
|
|
||||||
modules[cb.dataset.module] = cb.checked;
|
|
||||||
});
|
|
||||||
|
|
||||||
btn.disabled = true;
|
if (btn.dataset.action === "save") {
|
||||||
btn.textContent = "저장 중…";
|
const role = tr.querySelector("select[data-field='role']").value;
|
||||||
|
const modules = {};
|
||||||
|
tr.querySelectorAll(".erp-perm-chip").forEach((c) => {
|
||||||
|
modules[c.dataset.module] = c.classList.contains("is-on");
|
||||||
|
});
|
||||||
|
btn.disabled = true; btn.textContent = "저장 중…";
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: JSON.stringify({ role, modules }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
clearDirty(tr);
|
||||||
|
showToast(`${email} 저장 완료`, "info");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`저장 실패: ${err.message}`, "error");
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = "저장";
|
||||||
|
}
|
||||||
|
} else if (btn.dataset.action === "delete") {
|
||||||
|
if (!confirm(`${email} 사용자를 삭제할까요? 복구 불가.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
|
||||||
|
method: "DELETE", credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
tr.remove();
|
||||||
|
showToast(`${email} 삭제 완료`, "info");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`삭제 실패: ${err.message}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 사용자 추가
|
||||||
|
addForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(addForm);
|
||||||
|
const payload = {
|
||||||
|
email: fd.get("email").trim().toLowerCase(),
|
||||||
|
name: fd.get("name").trim(),
|
||||||
|
role: fd.get("role"),
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
|
const res = await fetch("/api/users", {
|
||||||
method: "PUT",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
body: JSON.stringify({ role, modules }),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.detail || `HTTP ${res.status}`);
|
throw new Error(data.detail || `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
clearDirty(tr);
|
showToast(`${payload.email} 등록 완료. 새로고침합니다.`, "info");
|
||||||
showToast(`${email} 저장 완료`, "info");
|
setTimeout(() => location.reload(), 800);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(`저장 실패: ${err.message}`, "error");
|
showToast(`등록 실패: ${err.message}`, "error");
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = "저장";
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 검색
|
|
||||||
searchInput.addEventListener("input", () => {
|
searchInput.addEventListener("input", () => {
|
||||||
const q = searchInput.value.toLowerCase().trim();
|
const q = searchInput.value.toLowerCase().trim();
|
||||||
let visible = 0;
|
let visible = 0;
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{{ page_title or "ERP" }} — DBX Corporation</title>
|
<title>{{ page_title or "ERP" }} — DBX Corporation</title>
|
||||||
<link rel="stylesheet" href="/static/erp.css" />
|
<link rel="stylesheet" href="/static/erp.css?v=20260615b" />
|
||||||
<link rel="stylesheet" href="/static/erp-shell.css" />
|
<link rel="stylesheet" href="/static/erp-shell.css?v=20260615b" />
|
||||||
|
<link rel="stylesheet" href="/static/erp-attach-viewer.css" />
|
||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="erp-body erp-app-body">
|
<body class="erp-body erp-app-body">
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
<div class="erp-sidebar-head">
|
<div class="erp-sidebar-head">
|
||||||
<a href="/" class="erp-sidebar-brand">
|
<a href="/" class="erp-sidebar-brand">
|
||||||
<img src="/static/dbx-logo.png" alt="DBX" class="erp-sidebar-logo" />
|
<img src="/static/dbx-logo.png" alt="DBX" class="erp-sidebar-logo" />
|
||||||
<span class="erp-sidebar-system">ERP</span>
|
<span class="erp-sidebar-system">DBX ERP System</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="erp-sidebar-toggle" id="erp-sidebar-toggle" aria-label="사이드바 접기">
|
<button type="button" class="erp-sidebar-toggle" id="erp-sidebar-toggle" aria-label="사이드바 접기">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||||
@@ -114,6 +115,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="erp-topbar-right">
|
<div class="erp-topbar-right">
|
||||||
|
{% block topbar_actions %}{% endblock %}
|
||||||
<div class="erp-user-chip">
|
<div class="erp-user-chip">
|
||||||
{% if user.picture %}
|
{% if user.picture %}
|
||||||
<img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
|
<img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
|
||||||
@@ -159,6 +161,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script src="/static/erp-attach-viewer.js" defer></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<a href="/" class="erp-brand">
|
<a href="/" class="erp-brand">
|
||||||
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
|
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
|
||||||
<span class="erp-brand-divider"></span>
|
<span class="erp-brand-divider"></span>
|
||||||
<span class="erp-brand-system">ERP 시스템</span>
|
<span class="erp-brand-system">DBX ERP System</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""프로젝트 공통 시간대 — 모든 시간은 한국 시간(KST, UTC+9)으로 표시/계산한다.
|
||||||
|
|
||||||
|
대한민국은 서머타임(DST)이 없으므로 고정 오프셋 +9 로 충분하다.
|
||||||
|
zoneinfo/tzdata 의존 없이 어디서나 동일하게 동작한다.
|
||||||
|
|
||||||
|
- DB 의 TIMESTAMPTZ 는 UTC 로 저장되고 psycopg 가 aware datetime(UTC)로 돌려준다.
|
||||||
|
표시 직전에 `to_kst_iso()` 로 KST 문자열로 변환한다.
|
||||||
|
- "오늘"/"지금" 판정은 `today_kst()` / `now_kst()` 를 쓴다(서버 로컬 TZ 무관).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
KST = timezone(timedelta(hours=9), name="KST")
|
||||||
|
|
||||||
|
|
||||||
|
def now_kst() -> datetime:
|
||||||
|
"""현재 시각(KST, tz-aware)."""
|
||||||
|
return datetime.now(KST)
|
||||||
|
|
||||||
|
|
||||||
|
def today_kst() -> date:
|
||||||
|
"""오늘 날짜(KST 기준)."""
|
||||||
|
return now_kst().date()
|
||||||
|
|
||||||
|
|
||||||
|
def now_kst_iso() -> str:
|
||||||
|
return now_kst().isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def to_kst_iso(dt: datetime | None, *, timespec: str = "seconds") -> str | None:
|
||||||
|
"""datetime → KST ISO 문자열. naive 는 UTC 로 간주 후 변환."""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(KST).isoformat(timespec=timespec)
|
||||||